mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 16:02:10 +00:00
Merge pull request #794 from warmbly/feat/unibox-rail-layout-per-member
feat: save the unibox scope rail's favorites, order and hidden rows to the member's account per workspace
This commit is contained in:
@@ -541,7 +541,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
|
||||
- `POST /getaway` (websocket bootstrap)
|
||||
- `GET /realtime/info`
|
||||
- `GET /me/danger-zone`, `POST /me/danger-zone/delete`, `DELETE /me/danger-zone/delete`
|
||||
- `GET /me/views/:view`, `PUT /me/views/:view`, `DELETE /me/views/:view` (the signed-in member's own column layout and sort for a dashboard list in the current workspace; `view` is `contacts` or `campaign_leads`. Personal to the session, so no API scope reaches it)
|
||||
- `GET /me/views/:view`, `PUT /me/views/:view`, `DELETE /me/views/:view` (the signed-in member's own column layout and sort for a dashboard list in the current workspace; `view` is `contacts` or `campaign_leads`, or `unibox_rail` for the unibox scope rail, which saves a `layout` of `favorites`, `hidden`, `order` and `section_order` instead of columns. Personal to the session, so no API scope reaches it)
|
||||
- `GET /invitations`, `POST /invitations/accept`
|
||||
- All of `/organization/*` (create, switch, members, invitations, transfer ownership, avatar, danger zone)
|
||||
- `GET /website-tracking/settings`, `PATCH /website-tracking/settings`, `POST /website-tracking/settings/rotate-key` (the [website tracking](/guides/website-tracking/) snippet's consent mode, location precision, allowed hosts and retention; JWT permission `MANAGE_SETTINGS`. The rotate is bodyless and safe to repeat, each call issues a new key)
|
||||
|
||||
@@ -91,7 +91,8 @@ Fields are named by their JSON key, with nested fields as a dotted path (`inner.
|
||||
|--------|---------|
|
||||
| `invalid_lead_status` | `POST /contacts/search` or `POST /contacts/export` was given a `lead_status` that is not one of the documented values |
|
||||
| `invalid_sort_by` | `POST /contacts/search`, `POST /contacts/export` or a bulk action's `all` selection was given a `sort_by` of the form `custom:<key>` whose key could never be a custom-field name (letters, numbers, underscores, spaces or dashes) |
|
||||
| `invalid_column`, `duplicate_column`, `too_many_columns`, `invalid_sort` | `PUT /me/views/:view` was given a column id that view cannot render, the same column twice, more than 64 columns, or a sort that names neither a sortable contact column nor a well-formed `custom:<key>` |
|
||||
| `invalid_column`, `duplicate_column`, `too_many_columns`, `invalid_sort` | `PUT /me/views/:view` was given a column id that view cannot render, the same column twice, more than 64 columns, or a sort that names neither a sortable contact column nor a well-formed `custom:<key>` (`unibox_rail` has no columns or sort at all) |
|
||||
| `invalid_layout` | `PUT /me/views/:view` was given a `layout` on a view that has none, or a `unibox_rail` layout with an unknown field, an empty or over-long key (200 bytes), more than 500 keys in a list or 32 sections, or a favorite name over 40 characters |
|
||||
| `invalid_mail_host` | `POST /contacts/search`, `POST /contacts/export` or a bulk action's `all` selection was given a `mail_hosts` entry that is not a documented provider value |
|
||||
| `invalid_engagement` | `POST /contacts/search` or `POST /contacts/export` was given an `engagement` that is not one of the documented values |
|
||||
| `lead_filter_requires_campaign` | `lead_status` or `engagement` was set without exactly one `campaign_ids` entry; both filters describe a contact inside one campaign |
|
||||
@@ -454,7 +455,7 @@ Returned when the requested resource doesn't exist.
|
||||
|
||||
| `code` | Meaning |
|
||||
|--------|---------|
|
||||
| `unknown_view` | `/me/views/:view` was given a view name other than `contacts` or `campaign_leads` |
|
||||
| `unknown_view` | `/me/views/:view` was given a view name other than `contacts`, `campaign_leads` or `unibox_rail` |
|
||||
| `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace |
|
||||
|
||||
### 409 Conflict
|
||||
|
||||
@@ -17,8 +17,8 @@ The columns are yours to size. Every choice here is remembered in the browser yo
|
||||
| The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. |
|
||||
| **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. |
|
||||
| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. |
|
||||
| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The **…** beside a header (or a right-click on it) folds every other section, unfolds them all, or moves the section up or down the rail. The pencil on **Mail** and **Views** puts the rows in edit mode: untick a row to hide it, drag it by its handle (or focus the handle and use the arrow keys) to reorder, and press **Done**, `Escape`, or click anywhere else. **Reset** puts that section back the way it shipped. Each row also has its own **…** menu and right-click menu to move it, hide it (with an undo), or mark a folder read, and `Alt` with an arrow key moves the focused row. Only one of these menus is open at a time: opening another, clicking or tapping anywhere else, or `Escape` closes it. A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. All of it is remembered in this browser. |
|
||||
| **Add to Favorites** in a row's **…** menu, or the star beside a row in the **Mail** and **Views** edit mode | Pins that scope to a **Favorites** section at the top of the scope rail, so the places you check every day sit together whichever section they come from: Inbox and Sent from Mail, a view such as Needs a reply, one mailbox, or a label or tag. The section only appears once something is in it, and each row also stays in its own section with the same live count. Favorites fold, move and reorder like the other sections (drag in edit mode, or `Alt` with an arrow key). **Rename** in a favorite's menu, `F2` on a focused favorite, or the pencil beside it in edit mode gives it a name of its own, such as "Inbox work", while its home row keeps the real one; clear the name to go back. Untick a favorite in edit mode, or choose **Remove from Favorites**, to take it out (with an undo), and **Remove all favorites** in the section's **…** menu empties it. The scope you are in is highlighted once, in Favorites unless you opened it from its own section. Like the rest of the rail, favorites are remembered in this browser and are not shared with teammates. |
|
||||
| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The **…** beside a header (or a right-click on it) folds every other section, unfolds them all, or moves the section up or down the rail. The pencil on **Mail** and **Views** puts the rows in edit mode: untick a row to hide it, drag it by its handle (or focus the handle and use the arrow keys) to reorder, and press **Done**, `Escape`, or click anywhere else. **Reset** puts that section back the way it shipped. Each row also has its own **…** menu and right-click menu to move it, hide it (with an undo), or mark a folder read, and `Alt` with an arrow key moves the focused row. Only one of these menus is open at a time: opening another, clicking or tapping anywhere else, or `Escape` closes it. A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. Hidden rows and the order of rows and sections are saved to your account for each workspace, so your phone and every other browser you sign in on show the same rail; which sections are folded is remembered per browser. |
|
||||
| **Add to Favorites** in a row's **…** menu, or the star beside a row in the **Mail** and **Views** edit mode | Pins that scope to a **Favorites** section at the top of the scope rail, so the places you check every day sit together whichever section they come from: Inbox and Sent from Mail, a view such as Needs a reply, one mailbox, or a label or tag. The section only appears once something is in it, and each row also stays in its own section with the same live count. Favorites fold, move and reorder like the other sections (drag in edit mode, or `Alt` with an arrow key). **Rename** in a favorite's menu, `F2` on a focused favorite, or the pencil beside it in edit mode gives it a name of its own, such as "Inbox work", while its home row keeps the real one; clear the name to go back. Untick a favorite in edit mode, or choose **Remove from Favorites**, to take it out (with an undo), and **Remove all favorites** in the section's **…** menu empties it. The scope you are in is highlighted once, in Favorites unless you opened it from its own section. Favorites, with their names, are saved to your account for each workspace like the rest of the rail's arrangement, so they follow you to your phone and any other browser, and they are not shared with teammates. A rail you arranged before this was saved to your account is picked up the first time you open the unibox. |
|
||||
| The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. |
|
||||
|
||||
How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor.
|
||||
|
||||
@@ -102,7 +102,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap
|
||||
| Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent |
|
||||
| Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history |
|
||||
| An invalid workspace name | An archive's workspace name is applied only when it passes the same [naming rules](/api/error-codes/#name-refusals) as a rename. Otherwise the destination keeps its own |
|
||||
| Personal list layouts | Which columns each member shows on the contacts list and how they sort it belongs to the person, not the workspace. Everyone starts from the default view on the new instance and picks their columns again |
|
||||
| Personal list layouts | Which columns each member shows on the contacts list and how they sort it, and how they arrange the unibox scope rail (Favorites, row order, hidden rows), belongs to the person, not the workspace. Everyone starts from the default view on the new instance and sets it up again |
|
||||
|
||||
An import runs as one transaction. If anything fails, nothing lands and the workspace is untouched.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,11 +43,13 @@ func (h *Handler) GetViewPreferences(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
// A field left out keeps its saved value; "columns": [] is the default layout
|
||||
// and "sort": {"by": ""} the default sort.
|
||||
// A field left out keeps its saved value; "columns": [] is the default layout,
|
||||
// "sort": {"by": ""} the default sort, and "layout": null the default layout
|
||||
// of a view that has one.
|
||||
type updateViewPreferencesRequest struct {
|
||||
Columns *[]string `json:"columns"`
|
||||
Sort *models.ViewSort `json:"sort"`
|
||||
Layout json.RawMessage `json:"layout"`
|
||||
}
|
||||
|
||||
// UpdateViewPreferences writes the caller's saved layout for one list: the
|
||||
@@ -62,7 +65,7 @@ func (h *Handler) UpdateViewPreferences(c *gin.Context) {
|
||||
errx.Handle(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
upd := models.ViewPreferencesUpdate{Columns: req.Columns, Sort: req.Sort}
|
||||
upd := models.ViewPreferencesUpdate{Columns: req.Columns, Sort: req.Sort, Layout: req.Layout}
|
||||
saved, xerr := h.ViewPreferencesService.Put(c.Request.Context(), uid, orgID, c.Param("view"), upd)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
|
||||
@@ -926,7 +926,7 @@ var ExcludedTables = map[string]string{
|
||||
"contact_imports": "Contact imports in progress or recently finished. They are work this instance is doing; the contacts they created travel with the contacts group.",
|
||||
"contact_import_rows": "The uploaded rows of a contact import and what became of each. They follow contact_imports, which does not travel.",
|
||||
"placement_renders": "The copy a tracking comparison is sending to each seed, sealed so both halves send the same words. It lives only while the comparison runs, and a copy that had not been sent stays behind with its task.",
|
||||
"user_view_preferences": "Each member's own column layout and sort for the dashboard's lists. It belongs to the person rather than the workspace: members are matched by account on import and a layout names custom fields the destination may not hold yet, so everyone starts from the default view and picks their columns again.",
|
||||
"user_view_preferences": "Each member's own column layout and sort for the dashboard's lists, and their unibox scope rail arrangement. It belongs to the person rather than the workspace: members are matched by account on import and a layout names custom fields the destination may not hold yet, so everyone starts from the default view and picks their columns again.",
|
||||
}
|
||||
|
||||
// TableByName indexes Tables for lookup during import.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Package viewprefs keeps each member's own layout of the dashboard's lists:
|
||||
// which columns show, in what order, and the sort. A layout is personal and
|
||||
// which columns show, in what order, and the sort, plus the unibox scope
|
||||
// rail's arrangement. A layout is personal and
|
||||
// per workspace, so two members of one workspace can look at the same contacts
|
||||
// through different columns.
|
||||
package viewprefs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -88,6 +91,9 @@ func (s *service) Reset(ctx context.Context, userID, orgID uuid.UUID, view strin
|
||||
// field. Custom-field ids are normalized in place so the same field saved with
|
||||
// different spacing is one column.
|
||||
func validate(view string, upd *models.ViewPreferencesUpdate) *errx.Error {
|
||||
if xerr := validateLayout(view, upd); xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
if upd.Columns != nil {
|
||||
cols := *upd.Columns
|
||||
if len(cols) > models.ViewPreferencesMaxColumns {
|
||||
@@ -117,6 +123,9 @@ func validate(view string, upd *models.ViewPreferencesUpdate) *errx.Error {
|
||||
upd.Sort = &models.ViewSort{}
|
||||
return nil
|
||||
}
|
||||
if models.ViewHasLayout(view) {
|
||||
return errx.NewWithIdentifier(errx.BadRequest, "invalid_sort", "this view has no sort")
|
||||
}
|
||||
norm, ok := normalizeID(by, models.ContactBuiltinSorts)
|
||||
if !ok {
|
||||
return errx.NewWithIdentifier(errx.BadRequest, "invalid_sort", "invalid sort: "+by)
|
||||
@@ -126,6 +135,38 @@ func validate(view string, upd *models.ViewPreferencesUpdate) *errx.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayout accepts a layout only on a view that has one, decoded into
|
||||
// its type and stored normalized; null is the default layout.
|
||||
func validateLayout(view string, upd *models.ViewPreferencesUpdate) *errx.Error {
|
||||
if upd.Layout == nil {
|
||||
return nil
|
||||
}
|
||||
invalid := func(msg string) *errx.Error {
|
||||
return errx.NewWithIdentifier(errx.BadRequest, "invalid_layout", msg)
|
||||
}
|
||||
if !models.ViewHasLayout(view) {
|
||||
return invalid("this view has no layout")
|
||||
}
|
||||
var layout models.UniboxRailLayout
|
||||
if string(upd.Layout) != "null" {
|
||||
dec := json.NewDecoder(bytes.NewReader(upd.Layout))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&layout); err != nil {
|
||||
return invalid("invalid layout")
|
||||
}
|
||||
}
|
||||
norm, ok := layout.Normalize()
|
||||
if !ok {
|
||||
return invalid("invalid layout: a key is empty or too long, a name is too long, or a list is too large")
|
||||
}
|
||||
raw, err := json.Marshal(norm)
|
||||
if err != nil {
|
||||
return invalid("invalid layout")
|
||||
}
|
||||
upd.Layout = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeID accepts a known built-in id as is, or a custom field with a
|
||||
// valid key, normalized.
|
||||
func normalizeID(id string, known map[string]bool) (string, bool) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package viewprefs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -72,3 +73,55 @@ func manyColumns(n int) *[]string {
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func TestValidateUniboxRailLayout(t *testing.T) {
|
||||
upd := models.ViewPreferencesUpdate{Layout: []byte(`{
|
||||
"favorites": [{"key": "folder:inbox", "name": " Inbox work "}, {"key": "folder:inbox"}],
|
||||
"hidden": ["view:today", "view:today"],
|
||||
"order": {"mail": ["folder:sent", "folder:inbox"]},
|
||||
"section_order": ["favorites", "mail"]
|
||||
}`)}
|
||||
if xerr := validate(models.ViewUniboxRail, &upd); xerr != nil {
|
||||
t.Fatalf("valid layout rejected: %v", xerr)
|
||||
}
|
||||
var got models.UniboxRailLayout
|
||||
if err := json.Unmarshal(upd.Layout, &got); err != nil {
|
||||
t.Fatalf("stored layout does not decode: %v", err)
|
||||
}
|
||||
if len(got.Favorites) != 1 || got.Favorites[0].Name != "Inbox work" {
|
||||
t.Fatalf("favorites not normalized: %+v", got.Favorites)
|
||||
}
|
||||
if len(got.Hidden) != 1 {
|
||||
t.Fatalf("hidden not deduplicated: %v", got.Hidden)
|
||||
}
|
||||
|
||||
null := models.ViewPreferencesUpdate{Layout: []byte(`null`)}
|
||||
if xerr := validate(models.ViewUniboxRail, &null); xerr != nil || string(null.Layout) == "null" {
|
||||
t.Fatalf("null is the default layout: %v %s", xerr, null.Layout)
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
view string
|
||||
upd models.ViewPreferencesUpdate
|
||||
code string
|
||||
}{
|
||||
"layout on a column view": {models.ViewContacts, models.ViewPreferencesUpdate{Layout: []byte(`{}`)}, "invalid_layout"},
|
||||
"unknown field": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`{"widths": 3}`)}, "invalid_layout"},
|
||||
"not an object": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`[]`)}, "invalid_layout"},
|
||||
"empty key": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`{"hidden": [""]}`)}, "invalid_layout"},
|
||||
"long key": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`{"hidden": ["` + strings.Repeat("a", models.UniboxRailKeyMaxLength+1) + `"]}`)}, "invalid_layout"},
|
||||
"control in key": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`{"hidden": ["a\u0007b"]}`)}, "invalid_layout"},
|
||||
"long name": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: []byte(`{"favorites": [{"key": "k", "name": "` + strings.Repeat("n", models.UniboxRailFavoriteNameMax+1) + `"}]}`)}, "invalid_layout"},
|
||||
"columns on the rail": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Columns: cols("name")}, "invalid_column"},
|
||||
"sort on the rail": {models.ViewUniboxRail, models.ViewPreferencesUpdate{Sort: &models.ViewSort{By: "created_at"}}, "invalid_sort"},
|
||||
} {
|
||||
xerr := validate(tc.view, &tc.upd)
|
||||
if xerr == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
}
|
||||
if xerr.Identifier != tc.code {
|
||||
t.Errorf("%s: code %q, want %q", name, xerr.Identifier, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DELETE FROM public.user_view_preferences WHERE view = 'unibox_rail';
|
||||
ALTER TABLE public.user_view_preferences DROP CONSTRAINT IF EXISTS user_view_preferences_layout_check;
|
||||
ALTER TABLE public.user_view_preferences DROP COLUMN IF EXISTS layout;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A view's layout document, for views that save one instead of columns and a
|
||||
-- sort: the unibox scope rail's favorites, order and hidden rows. Read back
|
||||
-- whole and validated by the app on write, never filtered in SQL.
|
||||
ALTER TABLE public.user_view_preferences
|
||||
ADD COLUMN IF NOT EXISTS layout jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
ALTER TABLE public.user_view_preferences
|
||||
DROP CONSTRAINT IF EXISTS user_view_preferences_layout_check;
|
||||
ALTER TABLE public.user_view_preferences
|
||||
ADD CONSTRAINT user_view_preferences_layout_check CHECK (jsonb_typeof(layout) = 'object');
|
||||
@@ -1,8 +1,11 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Dashboard lists whose layout a member can save. A new list gets a constant
|
||||
@@ -10,12 +13,22 @@ import (
|
||||
const (
|
||||
ViewContacts = "contacts"
|
||||
ViewCampaignLeads = "campaign_leads"
|
||||
// ViewUniboxRail is the unibox scope rail. It has no columns or sort; its
|
||||
// layout is a UniboxRailLayout.
|
||||
ViewUniboxRail = "unibox_rail"
|
||||
)
|
||||
|
||||
// KnownViews is the set of view names GET/PUT /me/views/:view accepts.
|
||||
var KnownViews = map[string]bool{
|
||||
ViewContacts: true,
|
||||
ViewCampaignLeads: true,
|
||||
ViewUniboxRail: true,
|
||||
}
|
||||
|
||||
// ViewHasLayout reports whether a view saves a layout document rather than
|
||||
// columns and a sort.
|
||||
func ViewHasLayout(view string) bool {
|
||||
return view == ViewUniboxRail
|
||||
}
|
||||
|
||||
// ViewBuiltinColumns lists the built-in column ids each view can render, in
|
||||
@@ -54,10 +67,13 @@ type ViewSort struct {
|
||||
// An empty Columns means "the default layout"; a nil Sort means "the default
|
||||
// sort".
|
||||
type ViewPreferences struct {
|
||||
View string `json:"view"`
|
||||
Columns []string `json:"columns"`
|
||||
Sort *ViewSort `json:"sort,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
View string `json:"view"`
|
||||
Columns []string `json:"columns"`
|
||||
Sort *ViewSort `json:"sort,omitempty"`
|
||||
// Layout is the saved document of a view ViewHasLayout names, already
|
||||
// validated on write; absent when none is saved.
|
||||
Layout json.RawMessage `json:"layout,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ViewPreferencesUpdate is a partial write: a nil field keeps what is saved,
|
||||
@@ -65,6 +81,101 @@ type ViewPreferences struct {
|
||||
type ViewPreferencesUpdate struct {
|
||||
Columns *[]string
|
||||
Sort *ViewSort
|
||||
Layout json.RawMessage
|
||||
}
|
||||
|
||||
// UniboxRailLayout is how one member arranges the unibox scope rail in one
|
||||
// workspace. Every key is a rail scope key ("folder:inbox", "mailbox:<id>");
|
||||
// the rail ignores keys it no longer shows, so none is checked against the
|
||||
// workspace. Section folds and pane widths are per device and not part of it.
|
||||
type UniboxRailLayout struct {
|
||||
Favorites []UniboxRailFavorite `json:"favorites"`
|
||||
Hidden []string `json:"hidden"`
|
||||
Order map[string][]string `json:"order"`
|
||||
SectionOrder []string `json:"section_order"`
|
||||
}
|
||||
|
||||
// UniboxRailFavorite is a scope pinned to the rail's Favorites section, with
|
||||
// an optional name the member gave it.
|
||||
type UniboxRailFavorite struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Bounds on a rail layout, far above what a rail holds.
|
||||
const (
|
||||
UniboxRailMaxKeys = 500
|
||||
UniboxRailMaxSections = 32
|
||||
UniboxRailKeyMaxLength = 200
|
||||
UniboxRailFavoriteNameMax = 40
|
||||
)
|
||||
|
||||
// Normalize validates the layout and returns it with duplicate keys dropped,
|
||||
// favorite names trimmed and nil lists made empty, or false when anything is
|
||||
// out of bounds.
|
||||
func (l UniboxRailLayout) Normalize() (UniboxRailLayout, bool) {
|
||||
out := UniboxRailLayout{Order: map[string][]string{}}
|
||||
var ok bool
|
||||
if out.Hidden, ok = railKeys(l.Hidden, UniboxRailMaxKeys); !ok {
|
||||
return out, false
|
||||
}
|
||||
if out.SectionOrder, ok = railKeys(l.SectionOrder, UniboxRailMaxSections); !ok {
|
||||
return out, false
|
||||
}
|
||||
if len(l.Order) > UniboxRailMaxSections {
|
||||
return out, false
|
||||
}
|
||||
for section, keys := range l.Order {
|
||||
if !validRailKey(section) {
|
||||
return out, false
|
||||
}
|
||||
if out.Order[section], ok = railKeys(keys, UniboxRailMaxKeys); !ok {
|
||||
return out, false
|
||||
}
|
||||
}
|
||||
if len(l.Favorites) > UniboxRailMaxKeys {
|
||||
return out, false
|
||||
}
|
||||
out.Favorites = make([]UniboxRailFavorite, 0, len(l.Favorites))
|
||||
seen := make(map[string]bool, len(l.Favorites))
|
||||
for _, f := range l.Favorites {
|
||||
if !validRailKey(f.Key) {
|
||||
return out, false
|
||||
}
|
||||
name := strings.Join(strings.Fields(f.Name), " ")
|
||||
if utf8.RuneCountInString(name) > UniboxRailFavoriteNameMax || strings.IndexFunc(name, unicode.IsControl) >= 0 {
|
||||
return out, false
|
||||
}
|
||||
if seen[f.Key] {
|
||||
continue
|
||||
}
|
||||
seen[f.Key] = true
|
||||
out.Favorites = append(out.Favorites, UniboxRailFavorite{Key: f.Key, Name: name})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func railKeys(keys []string, limit int) ([]string, bool) {
|
||||
if len(keys) > limit {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(keys))
|
||||
seen := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
if !validRailKey(k) {
|
||||
return nil, false
|
||||
}
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func validRailKey(k string) bool {
|
||||
return k != "" && len(k) <= UniboxRailKeyMaxLength && utf8.ValidString(k) &&
|
||||
strings.IndexFunc(k, unicode.IsControl) < 0
|
||||
}
|
||||
|
||||
// ContactSortCustomPrefix is the sort_by form that orders the contacts list on
|
||||
|
||||
@@ -32,18 +32,22 @@ func NewViewPreferencesRepository(db *pgxpool.Pool) ViewPreferencesRepository {
|
||||
return &viewPreferencesRepository{db: db}
|
||||
}
|
||||
|
||||
// scanPrefs reads one row's columns, sort and updated_at.
|
||||
// scanPrefs reads one row's columns, sort, layout and updated_at.
|
||||
func scanPrefs(view string, row pgx.Row) (*models.ViewPreferences, error) {
|
||||
var (
|
||||
raw []byte
|
||||
sortBy string
|
||||
reverse bool
|
||||
layout []byte
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err := row.Scan(&raw, &sortBy, &reverse, &updatedAt); err != nil {
|
||||
if err := row.Scan(&raw, &sortBy, &reverse, &layout, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefs := &models.ViewPreferences{View: view, Columns: []string{}, UpdatedAt: &updatedAt}
|
||||
if models.ViewHasLayout(view) && len(layout) > 0 {
|
||||
prefs.Layout = layout
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &prefs.Columns); err != nil {
|
||||
return nil, err
|
||||
@@ -57,7 +61,7 @@ func scanPrefs(view string, row pgx.Row) (*models.ViewPreferences, error) {
|
||||
|
||||
func (r *viewPreferencesRepository) Get(ctx context.Context, userID, orgID uuid.UUID, view string) (*models.ViewPreferences, error) {
|
||||
prefs, err := scanPrefs(view, r.db.QueryRow(ctx, `
|
||||
SELECT columns, sort_by, sort_reverse, updated_at
|
||||
SELECT columns, sort_by, sort_reverse, layout, updated_at
|
||||
FROM user_view_preferences
|
||||
WHERE user_id = $1 AND organization_id = $2 AND view = $3`,
|
||||
userID, orgID, view))
|
||||
@@ -87,16 +91,21 @@ func (r *viewPreferencesRepository) Upsert(ctx context.Context, userID, orgID uu
|
||||
if upd.Sort != nil {
|
||||
sortBy, reverse = &upd.Sort.By, &upd.Sort.Reverse
|
||||
}
|
||||
var layout []byte
|
||||
if upd.Layout != nil {
|
||||
layout = upd.Layout
|
||||
}
|
||||
return scanPrefs(view, r.db.QueryRow(ctx, `
|
||||
INSERT INTO user_view_preferences (user_id, organization_id, view, columns, sort_by, sort_reverse, updated_at)
|
||||
VALUES ($1, $2, $3, COALESCE($4::jsonb, '[]'::jsonb), COALESCE($5::text, ''), COALESCE($6::boolean, false), now())
|
||||
INSERT INTO user_view_preferences (user_id, organization_id, view, columns, sort_by, sort_reverse, layout, updated_at)
|
||||
VALUES ($1, $2, $3, COALESCE($4::jsonb, '[]'::jsonb), COALESCE($5::text, ''), COALESCE($6::boolean, false), COALESCE($7::jsonb, '{}'::jsonb), now())
|
||||
ON CONFLICT (user_id, organization_id, view) DO UPDATE
|
||||
SET columns = COALESCE($4::jsonb, user_view_preferences.columns),
|
||||
sort_by = COALESCE($5::text, user_view_preferences.sort_by),
|
||||
sort_reverse = COALESCE($6::boolean, user_view_preferences.sort_reverse),
|
||||
layout = COALESCE($7::jsonb, user_view_preferences.layout),
|
||||
updated_at = now()
|
||||
RETURNING columns, sort_by, sort_reverse, updated_at`,
|
||||
userID, orgID, view, raw, sortBy, reverse))
|
||||
RETURNING columns, sort_by, sort_reverse, layout, updated_at`,
|
||||
userID, orgID, view, raw, sortBy, reverse, layout))
|
||||
}
|
||||
|
||||
func (r *viewPreferencesRepository) Delete(ctx context.Context, userID, orgID uuid.UUID, view string) error {
|
||||
|
||||
@@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -100,3 +102,62 @@ func TestLiveViewPreferencesPartialWrites(t *testing.T) {
|
||||
t.Fatalf("delete left a row: %v %+v", err, gone)
|
||||
}
|
||||
}
|
||||
|
||||
// The rail's layout is a third part of the same row: writing it keeps the
|
||||
// rest, and a later write without it keeps the layout.
|
||||
func TestLiveViewPreferencesUniboxRailLayout(t *testing.T) {
|
||||
handle, pool := liveContactDB(t)
|
||||
ctx := context.Background()
|
||||
user, org := uuid.New(), uuid.New()
|
||||
tag := org.String()[:8]
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash) VALUES ($1, 'Rail', 'Live', $2, 'x')`, user, "i779-"+tag+"@test.local")
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue 779 rail', $2, $3)`, org, "i779-"+tag, user)
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
_, _ = pool.Exec(c, `DELETE FROM organizations WHERE id = $1`, org)
|
||||
_, _ = pool.Exec(c, `DELETE FROM users WHERE id = $1`, user)
|
||||
})
|
||||
repo := NewViewPreferencesRepository(handle.Pool)
|
||||
|
||||
layout := []byte(`{"favorites":[{"key":"folder:inbox","name":"Inbox work"}],"hidden":["view:today"],"order":{"mail":["folder:sent","folder:inbox"]},"section_order":["favorites","mail"]}`)
|
||||
got, err := repo.Upsert(ctx, user, org, models.ViewUniboxRail, models.ViewPreferencesUpdate{Layout: layout})
|
||||
if err != nil {
|
||||
t.Fatalf("layout insert: %v", err)
|
||||
}
|
||||
if !jsonEqual(t, got.Layout, layout) {
|
||||
t.Fatalf("saved layout %s, want %s", got.Layout, layout)
|
||||
}
|
||||
|
||||
// An update that does not name the layout keeps it.
|
||||
if _, err := repo.Upsert(ctx, user, org, models.ViewUniboxRail, models.ViewPreferencesUpdate{}); err != nil {
|
||||
t.Fatalf("empty update: %v", err)
|
||||
}
|
||||
read, err := repo.Get(ctx, user, org, models.ViewUniboxRail)
|
||||
if err != nil || read == nil || !jsonEqual(t, read.Layout, layout) {
|
||||
t.Fatalf("read back %v %+v", err, read)
|
||||
}
|
||||
|
||||
// The column views never report a layout of their own.
|
||||
cols, err := repo.Upsert(ctx, user, org, models.ViewContacts, models.ViewPreferencesUpdate{Columns: &[]string{"company"}})
|
||||
if err != nil || cols.Layout != nil {
|
||||
t.Fatalf("contacts carries a layout: %v %s", err, cols.Layout)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonEqual(t *testing.T, a, b []byte) bool {
|
||||
t.Helper()
|
||||
var x, y any
|
||||
if err := json.Unmarshal(a, &x); err != nil {
|
||||
t.Fatalf("decode %s: %v", a, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &y); err != nil {
|
||||
t.Fatalf("decode %s: %v", b, err)
|
||||
}
|
||||
return reflect.DeepEqual(x, y)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useMediaQuery, LG_QUERY } from "@/hooks/useMediaQuery";
|
||||
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UniboxSearchParams } from "@/lib/api/models/app/unibox/UniboxSearch";
|
||||
import { useUniboxRailSync } from "@/hooks/useUniboxRailSync";
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
@@ -52,6 +53,8 @@ function startOfWeek(): Date {
|
||||
}
|
||||
|
||||
export default function UniboxPage() {
|
||||
// The rail and its phone sheet both read the store this keeps in step with the account.
|
||||
useUniboxRailSync();
|
||||
const access = useFeatureAccess();
|
||||
const canAccess = usePermission("ACCESS_UNIBOX");
|
||||
const overview = useUniboxOverview();
|
||||
|
||||
@@ -97,7 +97,7 @@ import { ColumnChooser, SortMenu, type ViewSortState } from "./ViewControls";
|
||||
import { useContactView } from "./useContactView";
|
||||
import { readCachedView } from "@/lib/api/hooks/app/views/useViewPreferences";
|
||||
import type { SearchContactsSortBy } from "@/lib/api/models/app/contacts/search-contacts.types";
|
||||
import type { ViewName } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import type { ColumnViewName } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
|
||||
import {
|
||||
EmptyBlock,
|
||||
@@ -202,7 +202,7 @@ export default function ContactsTable({
|
||||
|
||||
// The member's saved layout for this list: its columns and its sort. The
|
||||
// Leads tab and the contacts page are two views with two layouts.
|
||||
const viewName: ViewName = current_campaign ? "campaign_leads" : "contacts";
|
||||
const viewName: ColumnViewName = current_campaign ? "campaign_leads" : "contacts";
|
||||
const view = useContactView(viewName);
|
||||
|
||||
const [searchProps, setSearchProps] = React.useState<SearchContacts>(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import clippedTitle from "@/lib/helper/clippedTitle";
|
||||
import type { ContactCampaignProgress, VerificationSource, VerificationStatus } from "@/lib/api/models/app/contacts/Contact";
|
||||
import type { SearchContactsSortBy } from "@/lib/api/models/app/contacts/search-contacts.types";
|
||||
import type { ViewName } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import type { ColumnViewName } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import { CategoryChip } from "./CategoryPicker";
|
||||
import VerificationBadge from "./VerificationBadge";
|
||||
import ContactAvatar from "./ContactAvatar";
|
||||
@@ -177,7 +177,7 @@ const nameColumn: ContactColumn = {
|
||||
// leave. Which breakpoint each sized column appears at is then just "does Name
|
||||
// still clear ~170px": Leads carries five campaign columns Contacts does not, so
|
||||
// company waits longer for room there.
|
||||
function companyColumn(view: ViewName): ContactColumn {
|
||||
function companyColumn(view: ColumnViewName): ContactColumn {
|
||||
return {
|
||||
id: "company",
|
||||
label: "Company",
|
||||
@@ -333,7 +333,7 @@ const campaignsColumn: ContactColumn = {
|
||||
|
||||
const dateCell = "font-mono text-[11px] text-slate-500 tabular-nums";
|
||||
|
||||
const addedColumn = (view: ViewName): ContactColumn => ({
|
||||
const addedColumn = (view: ColumnViewName): ContactColumn => ({
|
||||
id: "created_at",
|
||||
label: "Added",
|
||||
width: view === "campaign_leads" ? "w-32" : "w-24",
|
||||
@@ -387,7 +387,7 @@ export function customColumn(key: string): ContactColumn {
|
||||
|
||||
// Every built-in column a view can show, in its natural order. The chooser
|
||||
// lists them in this order under "Available".
|
||||
export function builtinColumns(view: ViewName): ContactColumn[] {
|
||||
export function builtinColumns(view: ColumnViewName): ContactColumn[] {
|
||||
if (view === "campaign_leads") {
|
||||
return [
|
||||
nameColumn,
|
||||
@@ -428,7 +428,7 @@ export function emptyColumnIds(columns: ContactColumn[], rows: ContactRow[]): Se
|
||||
}
|
||||
|
||||
// The layout a member sees before choosing anything.
|
||||
export const DEFAULT_COLUMNS: Record<ViewName, string[]> = {
|
||||
export const DEFAULT_COLUMNS: Record<ColumnViewName, string[]> = {
|
||||
contacts: ["name", "company", "phone", "status", "campaigns", "created_at"],
|
||||
campaign_leads: ["name", "company", "progress", "opened", "clicked", "replied", "current_step", "sender", "last_activity"],
|
||||
};
|
||||
@@ -440,7 +440,7 @@ export const DEFAULT_COLUMNS: Record<ViewName, string[]> = {
|
||||
// is kept even when no contact currently carries it, so the column the member
|
||||
// picked does not vanish because an import replaced the field's name.
|
||||
export function resolveColumns(
|
||||
view: ViewName,
|
||||
view: ColumnViewName,
|
||||
saved: string[] | undefined,
|
||||
customKeys: string[],
|
||||
): { visible: ContactColumn[]; available: ContactColumn[] } {
|
||||
@@ -473,7 +473,7 @@ export interface SortOption {
|
||||
}
|
||||
|
||||
// The sort choices the toolbar menu offers, beyond what a header click reaches.
|
||||
export function sortOptions(view: ViewName): SortOption[] {
|
||||
export function sortOptions(view: ColumnViewName): SortOption[] {
|
||||
const base: SortOption[] = [
|
||||
{ key: "created_at", label: "Date added", asc: false },
|
||||
{ key: "updated_at", label: "Last updated", asc: false },
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
useViewPreferences,
|
||||
type ViewScope,
|
||||
} from "@/lib/api/hooks/app/views/useViewPreferences";
|
||||
import type { ViewName, ViewSort } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import type { ColumnViewName, ViewSort } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import { resolveColumns } from "./columns";
|
||||
|
||||
export function useContactView(view: ViewName) {
|
||||
export function useContactView(view: ColumnViewName) {
|
||||
const org = useCurrentOrg();
|
||||
const user = useUser();
|
||||
const scope = React.useMemo<ViewScope>(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// The scope rail follows the member across devices: what the account holds
|
||||
// is applied, edits go up, and a rail from another workspace never does.
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { UniboxRailLayout, ViewPreferences } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
|
||||
const server = vi.hoisted(() => ({
|
||||
saved: null as null | { layout: unknown },
|
||||
puts: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/client/app/views/views", () => ({
|
||||
getViewPreferences: async () => ({
|
||||
preferences: server.saved
|
||||
? { view: "unibox_rail", columns: [], layout: server.saved.layout, updated_at: new Date() }
|
||||
: { view: "unibox_rail", columns: [] },
|
||||
}),
|
||||
updateViewPreferences: async (_view: string, body: { layout: unknown }) => {
|
||||
server.puts.push(body.layout);
|
||||
server.saved = { layout: body.layout };
|
||||
return { preferences: { view: "unibox_rail", columns: [], layout: body.layout, updated_at: new Date() } as ViewPreferences };
|
||||
},
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: () => {}, success: () => {} } }));
|
||||
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { useUniboxRailSync } from "./useUniboxRailSync";
|
||||
|
||||
const OWNER = "u1:org-1";
|
||||
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderHook(() => useUniboxRailSync(), {
|
||||
wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>,
|
||||
});
|
||||
}
|
||||
|
||||
function setRail(l: Partial<UniboxRailLayout>, owner: string | null) {
|
||||
useAppStore.setState({
|
||||
uniboxRailFavorites: l.favorites ?? [],
|
||||
uniboxRailHidden: l.hidden ?? [],
|
||||
uniboxRailOrder: l.order ?? {},
|
||||
uniboxRailSectionOrder: l.section_order ?? [],
|
||||
uniboxRailOwner: owner,
|
||||
});
|
||||
}
|
||||
|
||||
describe("useUniboxRailSync", () => {
|
||||
beforeEach(() => {
|
||||
server.saved = null;
|
||||
server.puts = [];
|
||||
useAppStore.setState({
|
||||
user: { id: "u1" } as never,
|
||||
currentOrganization: { id: "org-1" } as never,
|
||||
});
|
||||
setRail({}, null);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it("applies the rail the account holds over what this browser had", async () => {
|
||||
server.saved = { layout: { favorites: [{ key: "folder:inbox", name: "Work" }], hidden: ["view:today"], order: {}, section_order: [] } };
|
||||
setRail({ favorites: [{ key: "folder:sent" }] }, OWNER);
|
||||
mount();
|
||||
await waitFor(() => expect(useAppStore.getState().uniboxRailFavorites).toEqual([{ key: "folder:inbox", name: "Work" }]));
|
||||
expect(useAppStore.getState().uniboxRailHidden).toEqual(["view:today"]);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
expect(server.puts).toEqual([]);
|
||||
});
|
||||
|
||||
it("uploads a rail arranged here before it was saved to the account", async () => {
|
||||
setRail({ favorites: [{ key: "folder:inbox" }] }, null);
|
||||
mount();
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||
expect(server.puts[0]).toMatchObject({ favorites: [{ key: "folder:inbox" }] });
|
||||
expect(useAppStore.getState().uniboxRailOwner).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("starts another workspace's rail from the default instead of copying it", async () => {
|
||||
setRail({ favorites: [{ key: "folder:inbox" }], hidden: ["view:today"] }, "u1:org-2");
|
||||
mount();
|
||||
await waitFor(() => expect(useAppStore.getState().uniboxRailOwner).toBe(OWNER));
|
||||
expect(useAppStore.getState().uniboxRailFavorites).toEqual([]);
|
||||
expect(useAppStore.getState().uniboxRailHidden).toEqual([]);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
expect(server.puts).toEqual([]);
|
||||
});
|
||||
|
||||
it("saves an edit once the rail settles", async () => {
|
||||
server.saved = { layout: { favorites: [], hidden: [], order: {}, section_order: [] } };
|
||||
mount();
|
||||
await waitFor(() => expect(useAppStore.getState().uniboxRailOwner).toBe(OWNER));
|
||||
|
||||
act(() => {
|
||||
useAppStore.getState().toggleUniboxRailFavorite("folder:inbox");
|
||||
useAppStore.getState().toggleUniboxRailRow("view:today");
|
||||
});
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 2000 });
|
||||
expect(server.puts[0]).toMatchObject({ favorites: [{ key: "folder:inbox" }], hidden: ["view:today"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// The scope rail's arrangement follows the member, not the browser: favorites,
|
||||
// row and section order and hidden rows are saved per workspace through
|
||||
// GET/PUT /me/views/unibox_rail, so a phone shows what the desktop set up. The
|
||||
// persisted store stays the copy that paints the first frame. Section folds
|
||||
// and pane widths are per device and never leave it.
|
||||
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getViewPreferences, updateViewPreferences } from "@/lib/api/client/app/views/views";
|
||||
import type { UniboxRailLayout, ViewPreferences } from "@/lib/api/models/app/views/ViewPreferences";
|
||||
import { useAppStore, useCurrentOrg, useUser, type AppStore } from "@/stores/useAppStore";
|
||||
import {
|
||||
sanitizeUniboxRailFavorites,
|
||||
sanitizeUniboxRailHidden,
|
||||
sanitizeUniboxRailOrder,
|
||||
} from "@/stores/slices/uiSlice";
|
||||
|
||||
// Long enough that a drag across several rows is one write.
|
||||
const SAVE_DELAY_MS = 600;
|
||||
|
||||
const EMPTY: UniboxRailLayout = { favorites: [], hidden: [], order: {}, section_order: [] };
|
||||
|
||||
const fromStore = (s: AppStore): UniboxRailLayout => ({
|
||||
favorites: s.uniboxRailFavorites,
|
||||
hidden: s.uniboxRailHidden,
|
||||
order: s.uniboxRailOrder,
|
||||
section_order: s.uniboxRailSectionOrder,
|
||||
});
|
||||
|
||||
const fromServer = (l: Partial<UniboxRailLayout> | null | undefined): UniboxRailLayout => ({
|
||||
favorites: sanitizeUniboxRailFavorites(l?.favorites),
|
||||
hidden: sanitizeUniboxRailHidden(l?.hidden),
|
||||
order: sanitizeUniboxRailOrder(l?.order),
|
||||
section_order: sanitizeUniboxRailHidden(l?.section_order),
|
||||
});
|
||||
|
||||
// The same arrangement prints the same whichever order its sections were written in.
|
||||
const fingerprint = (l: UniboxRailLayout): string =>
|
||||
JSON.stringify([
|
||||
l.favorites.map((f) => [f.key, f.name ?? ""]),
|
||||
l.hidden,
|
||||
Object.entries(l.order)
|
||||
.filter(([, keys]) => keys.length > 0)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
l.section_order,
|
||||
]);
|
||||
|
||||
const sameRail = (a: AppStore, b: AppStore) =>
|
||||
a.uniboxRailFavorites === b.uniboxRailFavorites &&
|
||||
a.uniboxRailHidden === b.uniboxRailHidden &&
|
||||
a.uniboxRailOrder === b.uniboxRailOrder &&
|
||||
a.uniboxRailSectionOrder === b.uniboxRailSectionOrder;
|
||||
|
||||
const apply = (l: UniboxRailLayout, owner: string) =>
|
||||
useAppStore.setState({
|
||||
uniboxRailFavorites: l.favorites,
|
||||
uniboxRailHidden: l.hidden,
|
||||
uniboxRailOrder: l.order,
|
||||
uniboxRailSectionOrder: l.section_order,
|
||||
uniboxRailOwner: owner,
|
||||
});
|
||||
|
||||
export function useUniboxRailSync() {
|
||||
const userId = useUser()?.id ?? "";
|
||||
const orgId = useCurrentOrg()?.id ?? "";
|
||||
const owner = userId && orgId ? `${userId}:${orgId}` : "";
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = React.useMemo(() => ["views", userId, orgId, "unibox_rail"], [userId, orgId]);
|
||||
const q = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => (await getViewPreferences("unibox_rail")).preferences ?? null,
|
||||
enabled: !!owner,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// What the account holds for this owner, as a fingerprint; null until known.
|
||||
const saved = React.useRef<string | null>(null);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const writing = React.useRef(0);
|
||||
|
||||
const save = React.useCallback(
|
||||
(layout: UniboxRailLayout) => {
|
||||
writing.current++;
|
||||
updateViewPreferences("unibox_rail", { layout })
|
||||
.then(({ preferences }) => {
|
||||
if (useAppStore.getState().uniboxRailOwner !== owner) return;
|
||||
saved.current = fingerprint(fromServer(preferences.layout));
|
||||
queryClient.setQueryData<ViewPreferences>(queryKey, preferences);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Couldn't save your inbox rail to your account. It's kept in this browser for now.", {
|
||||
id: "unibox-rail-save",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
writing.current--;
|
||||
});
|
||||
},
|
||||
[owner, queryClient, queryKey],
|
||||
);
|
||||
|
||||
// Edits go up a moment after the last one. Declared before the effect that
|
||||
// reads the server, so a new owner starts from nothing known.
|
||||
React.useEffect(() => {
|
||||
if (!owner) return;
|
||||
const unsubscribe = useAppStore.subscribe((state, prev) => {
|
||||
if (saved.current === null || sameRail(state, prev)) return;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
if (fingerprint(fromStore(state)) === saved.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
const now = useAppStore.getState();
|
||||
if (now.uniboxRailOwner !== owner) useAppStore.setState({ uniboxRailOwner: owner });
|
||||
save(fromStore(now));
|
||||
}, SAVE_DELAY_MS);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
// Leaving the page still saves; a workspace switch must not, because
|
||||
// the request would go out under the next workspace.
|
||||
const s = useAppStore.getState();
|
||||
if (`${s.user?.id ?? ""}:${s.currentOrganization?.id ?? ""}` === owner) save(fromStore(s));
|
||||
}
|
||||
saved.current = null;
|
||||
};
|
||||
}, [owner, save]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// A local edit on its way wins over what the server said before it.
|
||||
if (!owner || !q.data || timer.current || writing.current) return;
|
||||
const state = useAppStore.getState();
|
||||
if (q.data.updated_at) {
|
||||
const server = fromServer(q.data.layout);
|
||||
saved.current = fingerprint(server);
|
||||
if (state.uniboxRailOwner !== owner || fingerprint(fromStore(state)) !== saved.current) apply(server, owner);
|
||||
return;
|
||||
}
|
||||
// Nothing saved yet. A rail arranged in this browser before it was saved
|
||||
// to the account goes up once; any other member's or workspace's starts
|
||||
// from the default.
|
||||
saved.current = fingerprint(EMPTY);
|
||||
if (state.uniboxRailOwner !== null && state.uniboxRailOwner !== owner) {
|
||||
apply(EMPTY, owner);
|
||||
return;
|
||||
}
|
||||
useAppStore.setState({ uniboxRailOwner: owner });
|
||||
const local = fromStore(state);
|
||||
if (fingerprint(local) !== saved.current) save(local);
|
||||
}, [q.data, owner, save]);
|
||||
}
|
||||
@@ -2,7 +2,18 @@
|
||||
// columns shown (in order, Name implied first) and the sort. Mirrors
|
||||
// models.ViewPreferences on the backend.
|
||||
|
||||
export type ViewName = "contacts" | "campaign_leads";
|
||||
// The lists that save columns and a sort.
|
||||
export type ColumnViewName = "contacts" | "campaign_leads";
|
||||
export type ViewName = ColumnViewName | "unibox_rail";
|
||||
|
||||
// The unibox scope rail's arrangement, the layout of the unibox_rail view.
|
||||
// Mirrors models.UniboxRailLayout; keys are the rail's scope keys.
|
||||
export interface UniboxRailLayout {
|
||||
favorites: { key: string; name?: string }[];
|
||||
hidden: string[];
|
||||
order: Record<string, string[]>;
|
||||
section_order: string[];
|
||||
}
|
||||
|
||||
export interface ViewSort {
|
||||
by: string;
|
||||
@@ -15,6 +26,8 @@ export interface ViewPreferences {
|
||||
columns: string[];
|
||||
// Null or absent means the list's default sort.
|
||||
sort?: ViewSort | null;
|
||||
// Only on a view that saves a layout document (unibox_rail), once saved.
|
||||
layout?: Partial<UniboxRailLayout> | null;
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
|
||||
@@ -27,4 +40,5 @@ export interface ViewPreferencesEnvelope {
|
||||
export interface UpdateViewPreferences {
|
||||
columns?: string[];
|
||||
sort?: ViewSort;
|
||||
layout?: UniboxRailLayout | null;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface UISlice {
|
||||
uniboxRailSectionOrder: string[]
|
||||
// Favorites, in rail order; each key is a scopeKey from any section.
|
||||
uniboxRailFavorites: UniboxRailFavorite[]
|
||||
// Whose saved rail the four fields above hold ("<userId>:<orgId>"); null
|
||||
// for a layout made before the rail was saved to the account.
|
||||
uniboxRailOwner: string | null
|
||||
|
||||
// Theme
|
||||
theme: Theme
|
||||
@@ -200,6 +203,7 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
||||
uniboxRailOrder: {},
|
||||
uniboxRailSectionOrder: [],
|
||||
uniboxRailFavorites: [],
|
||||
uniboxRailOwner: null,
|
||||
|
||||
// Theme
|
||||
theme: getInitialTheme(),
|
||||
|
||||
@@ -54,6 +54,7 @@ export const useAppStore = create<AppStore>()(
|
||||
uniboxRailOrder: sanitizeUniboxRailOrder(p.uniboxRailOrder),
|
||||
uniboxRailSectionOrder: sanitizeUniboxRailHidden(p.uniboxRailSectionOrder),
|
||||
uniboxRailFavorites: sanitizeUniboxRailFavorites(p.uniboxRailFavorites),
|
||||
uniboxRailOwner: typeof p.uniboxRailOwner === 'string' ? p.uniboxRailOwner : null,
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -70,12 +71,14 @@ export const useAppStore = create<AppStore>()(
|
||||
uniboxListWidth: state.uniboxListWidth,
|
||||
uniboxRailWidth: state.uniboxRailWidth,
|
||||
uniboxContactRailOpen: state.uniboxContactRailOpen,
|
||||
// Unibox scope rail (folds, hidden rows, row and section order, favorites)
|
||||
// Unibox scope rail (folds, hidden rows, row and section order, favorites).
|
||||
// All but the folds are a cache of the member's saved rail (useUniboxRailSync).
|
||||
uniboxRailFolded: state.uniboxRailFolded,
|
||||
uniboxRailHidden: state.uniboxRailHidden,
|
||||
uniboxRailOrder: state.uniboxRailOrder,
|
||||
uniboxRailSectionOrder: state.uniboxRailSectionOrder,
|
||||
uniboxRailFavorites: state.uniboxRailFavorites,
|
||||
uniboxRailOwner: state.uniboxRailOwner,
|
||||
// Persist current organization selection
|
||||
currentOrganization: state.currentOrganization,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user