diff --git a/API Server/README.md b/API Server/README.md index 059ddf5..ed587fc 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -149,7 +149,7 @@ GET /api/auth/validate GET /api/auth/me GET /api/identity -# current user (profile / appearance / garage order / avatar / data / +# current user (profile / appearance / drag lock / garage order / avatar / data / # account lifecycle) GET /api/me PATCH /api/me DELETE /api/me POST /api/me/password @@ -193,8 +193,8 @@ POST /api/vehicle-providers/{provider}/import # which PATCH /api/me {carOrder} sets) GET /api/cars POST /api/cars GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id} -PUT /api/cars/{id}/view # which tabs + Information rows this car shows, - # and the order of the rows and the provider readings +PUT /api/cars/{id}/view # which tabs + Information rows this car shows, and + # the order of the tabs, the rows and the provider readings GET /api/cars/{id}/provider POST /api/cars/{id}/provider POST /api/cars/{id}/provider/sync GET /api/cars/{id}/service-records GET /api/cars/{id}/technical-checks diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go index c6f1003..2fc0b77 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -248,6 +248,19 @@ var hideableCarTabs = map[string]bool{ "fuel": true, "charging": true, "documents": true, "parts": true, "reminders": true, } +// arrangeableCarTabs are the tabs a car's page can be rearranged into, which is +// the hideable ones plus Information: it cannot be switched off, but there is no +// reason it has to stay at the front. Derived from hideableCarTabs so the two +// sets cannot drift as tabs are added. +var arrangeableCarTabs = func() map[string]bool { + out := make(map[string]bool, len(hideableCarTabs)+1) + for key := range hideableCarTabs { + out[key] = true + } + out["info"] = true + return out +}() + // hideableCarFields are the Information rows that can be switched off — every // one of them, since unlike the tabs there is no row the page needs to keep. // Mirrors the car.info.* labels the web app renders. @@ -295,11 +308,11 @@ func normalizeKeys(in []string, allowed map[string]bool, what string) ([]string, } // PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, which -// rows of the Information tab, and the order the Information rows and the -// connected service's headline readings are laid out in. Body: {hiddenTabs?: -// [...], hiddenFields?: [...], fieldOrder?: [...], metricOrder?: [...]}; only the -// lists present are written, so a client can rearrange one group without -// resending the others. Its own endpoint rather than fields on the car edit, so an ordinary +// rows of the Information tab, and the order the tabs, the Information rows and +// the connected service's headline readings are laid out in. Body: {hiddenTabs?: +// [...], hiddenFields?: [...], tabOrder?: [...], fieldOrder?: [...], +// metricOrder?: [...]}; only the lists present are written, so a client can +// rearrange one group without resending the others. Its own endpoint rather than fields on the car edit, so an ordinary // save of the car form — which sends every other field — can never reveal // something somebody deliberately switched off. Needs write access: the choice // belongs to the car, so it is the same permission as editing it. @@ -307,6 +320,7 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) { var in struct { HiddenTabs *[]string `json:"hiddenTabs"` HiddenFields *[]string `json:"hiddenFields"` + TabOrder *[]string `json:"tabOrder"` FieldOrder *[]string `json:"fieldOrder"` MetricOrder *[]string `json:"metricOrder"` } @@ -341,6 +355,18 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) { } payload["hidden_fields"] = fields } + if in.TabOrder != nil { + // A wider set than the hidden tabs: Information is arrangeable although it + // cannot be switched off. A partial list is accepted, and the tabs it + // leaves out follow the arranged ones — which is what puts a tab added in + // a later release at the end rather than in the middle of somebody's bar. + order, err := normalizeKeys(*in.TabOrder, arrangeableCarTabs, "tab") + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload["tab_order"] = order + } if in.FieldOrder != nil { // The same key set as the hidden fields, since every Information row can // be moved. A partial list is accepted rather than demanding all 14: the diff --git a/API Server/internal/api/cartabs_test.go b/API Server/internal/api/cartabs_test.go index 18c5f40..ea01faf 100644 --- a/API Server/internal/api/cartabs_test.go +++ b/API Server/internal/api/cartabs_test.go @@ -77,6 +77,38 @@ func TestNormalizeHiddenFields(t *testing.T) { } } +// The tabs arrange against a wider set than they hide against: Information +// cannot be switched off, but it can be moved off the front of the bar. +func TestNormalizeTabOrder(t *testing.T) { + got, err := normalizeKeys([]string{"reminders", "info", "fuel"}, arrangeableCarTabs, "tab") + if err != nil { + t.Fatalf("normalizeKeys: %v", err) + } + assertKeys(t, got, []string{"reminders", "info", "fuel"}) + + // Everything the bar renders has to be arrangeable — the hideable tabs plus + // Information, and nothing else. + for key := range hideableCarTabs { + if !arrangeableCarTabs[key] { + t.Errorf("tab %q should be arrangeable", key) + } + } + if !arrangeableCarTabs["info"] { + t.Error("the info tab should be arrangeable even though it cannot be hidden") + } + if len(arrangeableCarTabs) != len(hideableCarTabs)+1 { + t.Errorf("arrangeableCarTabs has %d entries, want the hideable tabs plus Information", len(arrangeableCarTabs)) + } + + // A field key is not a tab key, and an invented tab is still an error. + if _, err := normalizeKeys([]string{"vin"}, arrangeableCarTabs, "tab"); err == nil { + t.Error("normalizeKeys accepted a field key as a tab, want an error") + } + if _, err := normalizeKeys([]string{"info", "nonsense"}, arrangeableCarTabs, "tab"); err == nil { + t.Error("normalizeKeys accepted an unknown tab in an arrangement, want an error") + } +} + // The arrangement of the Information rows shares the field key set — every row // can be moved — but not the meaning: here the order of the list is the point, // so it has to survive validation exactly as it was sent. diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index d8c186b..6bcb45c 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -31,6 +31,7 @@ type userRecord struct { DateFormat string `json:"date_format"` Currency string `json:"currency"` FontSize string `json:"font_size"` + DragLocked bool `json:"drag_locked"` DeletionRequestedAt string `json:"deletion_requested_at"` Role string `json:"role"` Organization string `json:"organization"` @@ -72,6 +73,7 @@ func (rec userRecord) toModel() models.User { DateFormat: orDefault(rec.DateFormat, "YMD"), Currency: orDefault(rec.Currency, "USD"), FontSize: orDefault(rec.FontSize, "medium"), + DragLocked: rec.DragLocked, Role: orDefault(rec.Role, "user"), Created: rec.Created, @@ -132,6 +134,7 @@ type updateMeRequest struct { DateFormat *string `json:"dateFormat"` Currency *string `json:"currency"` FontSize *string `json:"fontSize"` + DragLocked *bool `json:"dragLocked"` CarOrder *[]string `json:"carOrder"` } @@ -240,6 +243,11 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) { } payload["font_size"] = *in.FontSize } + if in.DragLocked != nil { + // No value to validate — either the arrangements on this account's pages + // are held still or they are not. + payload["drag_locked"] = *in.DragLocked + } if in.CarOrder != nil { ids, err := normalizeCarOrder(*in.CarOrder) if err != nil { diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index bad3b50..494e18a 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -69,11 +69,12 @@ type carRecord struct { Updated string `json:"updated"` // Switched-off tabs and Information fields, plus the arrangements of the - // Information rows and the connected service's readings. Raw because - // PocketBase hands back whatever a json field holds — null on a car nobody - // has configured — which is not a []string. + // tabs, the Information rows and the connected service's readings. Raw + // because PocketBase hands back whatever a json field holds — null on a car + // nobody has configured — which is not a []string. HiddenTabs json.RawMessage `json:"hidden_tabs"` HiddenFields json.RawMessage `json:"hidden_fields"` + TabOrder json.RawMessage `json:"tab_order"` FieldOrder json.RawMessage `json:"field_order"` MetricOrder json.RawMessage `json:"metric_order"` } @@ -104,6 +105,7 @@ func (rec carRecord) toModel() models.Car { ProviderVehicleID: rec.ProviderVehicleID, HiddenTabs: decodeStringList(rec.HiddenTabs), HiddenFields: decodeStringList(rec.HiddenFields), + TabOrder: decodeStringList(rec.TabOrder), FieldOrder: decodeStringList(rec.FieldOrder), MetricOrder: decodeStringList(rec.MetricOrder), Owner: rec.Owner, diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 4b9689b..2c63425 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -46,9 +46,10 @@ var collectionsSchema = map[string][]fieldDef{ // release is on by default. Keys are validated in internal/api/cars.go. fJSON("hidden_tabs", 2000), fJSON("hidden_fields", 2000), - // The order the Information rows are laid out in, as field keys, and the - // same for the connected service's headline readings. Empty means the - // page's own default order. + // The order the tabs are laid out in, as tab keys, the same for the + // Information rows, and the same for the connected service's headline + // readings. Empty means the page's own default order. + fJSON("tab_order", 2000), fJSON("field_order", 2000), fJSON("metric_order", 2000), // Owner of this car. Non-cascading: deleting a user must not wipe their cars. @@ -191,6 +192,10 @@ var collectionsSchema = map[string][]fieldDef{ "TRY", "UAH", "USD", "CAD", "AUD", "JPY", }, false), fSelect("font_size", []string{"small", "medium", "large"}, false), + // Holds every arrangement on this user's pages still — the garage, a + // car's tabs and Information rows, the provider's readings — so reading a + // page cannot nudge its layout. Per user, like the garage order. + fBool("drag_locked"), fDate("deletion_requested_at", false), // Access role. Empty value is treated as "user" by the API. fSelect("role", []string{"user", "admin", "superadmin"}, false), diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 2d625f5..14ed78c 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -89,6 +89,12 @@ type Car struct { // list, and joins the end when it does turn up. MetricOrder []string `json:"metricOrder"` + // TabOrder is the order of the tabs themselves, as tab keys. It covers the + // hidden tabs too, like FieldOrder, so a tab switched back on returns to + // where it was, and it includes "info" — Information cannot be switched off + // but it can be moved off the front. + TabOrder []string `json:"tabOrder"` + // Owner is the user id that owns this car. Access is the requesting user's // permission on it — "owner", "write", or "read" — computed by the API at // read time and never persisted (omitempty; not part of the write payload). @@ -427,6 +433,13 @@ type User struct { FontSize string `json:"fontSize"` // small | medium | large Role string `json:"role"` // user | admin + // DragLocked holds every arrangement on this account's pages still: the + // garage, a car's tabs, its Information rows, the provider's readings. A + // guard against nudging a layout while reading it, not a permission — it is + // the user's own setting and says nothing about what they may edit. False + // (draggable) on an account that has never set it. + DragLocked bool `json:"dragLocked"` + // Organization membership. Empty when the user belongs to no organization — // the clients use that to offer creating one (which makes them its admin). Organization string `json:"organization"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 547e436..d930046 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -279,10 +279,12 @@ const DESIRED = { // default. Keys are validated in internal/api/cars.go. F.json("hidden_tabs", 2000), F.json("hidden_fields", 2000), - // The order the Information rows are laid out in, as field keys — the - // hidden ones included, so a row switched back on returns to where it was. - // Empty means the page's own default order. metric_order is the same for - // the headline readings on the connected service's tab. + // The order the tabs are laid out in, as tab keys, and the order the + // Information rows are laid out in, as field keys — the hidden ones + // included in both, so one switched back on returns to where it was. Empty + // means the page's own default order. metric_order is the same for the + // headline readings on the connected service's tab. + F.json("tab_order", 2000), F.json("field_order", 2000), F.json("metric_order", 2000), // Owner of this car. Non-cascading on purpose: deleting a user must not @@ -464,6 +466,10 @@ const DESIRED = { "TRY", "UAH", "USD", "CAD", "AUD", "JPY", ]), F.select("font_size", ["small", "medium", "large"]), + // Holds every arrangement on this user's pages still — the garage, a car's + // tabs and Information rows, the provider's readings — so reading a page + // cannot nudge its layout. Per user, like car_order. + F.bool("drag_locked"), F.date("deletion_requested_at"), // Access role. Empty value is treated as "user" by the API. F.select("role", ["user", "admin", "superadmin"]), diff --git a/Web App/README.md b/Web App/README.md index 3d3a8b6..8bd1af7 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -101,6 +101,19 @@ Config (`server/.env`, copy from `.env.example`): the same page; setting it needs write access. Stored as the *hidden* sets, so anything added in a later release is on by default, and the Information tab itself can't be switched off. +- **Locking the layout** — the padlock in the sidebar, above the theme toggle, + holds every arrangement still at once: the garage, a car's tabs, its + Information rows, the provider's readings. It is a guard against nudging a + layout while reading it, not a permission — it is the user's own setting + (`dragLocked` on the profile, so it follows them to the next device) and says + nothing about what they may edit. Unlocked by default; while locked the drag + cursor and the hints go too. +- **Arranging the tabs** — the tabs themselves drag into any order, saved on + drop. A property of the car like the choice of which tabs show, so everyone it + is shared with sees the same bar, and it needs write access. It covers the + hidden tabs too, so switching one back on returns it to where it was, and + Information is arrangeable even though it can't be switched off. Where the + page opens is unchanged: Information, wherever it now sits in the bar. - **Arranging the Information rows** — the rows on a car's Information tab drag into any order, saved on drop. Also a property of the car, and it covers the hidden rows too, so switching one back on returns it to where it was. Same diff --git a/Web App/web/src/App.vue b/Web App/web/src/App.vue index 631f0fc..eacbc81 100644 --- a/Web App/web/src/App.vue +++ b/Web App/web/src/App.vue @@ -28,6 +28,18 @@ function toggleTheme() { if (isAuthenticated.value) api.updateMe({ theme: next }).catch(() => {}); } +// Drag lock (on ⇄ off), the same shape as the theme toggle: applies instantly +// and persists to the profile, so a locked account stays locked on the next +// device. It holds every arrangement still — the garage, a car's tabs, its +// Information rows, the provider's readings — which is a guard against nudging +// a layout while reading it, not a permission: what a user may edit is +// unchanged. +function toggleDragLock() { + const next = !prefs.dragLocked; + prefs.dragLocked = next; + if (isAuthenticated.value) api.updateMe({ dragLocked: next }).catch(() => {}); +} + const userInitial = computed(() => (state.user?.name || state.user?.email || "?").charAt(0).toUpperCase() ); @@ -90,8 +102,20 @@ onBeforeUnmount(() => themeObserver?.disconnect()); - +
{{ tabOrderError }}