diff --git a/API Server/README.md b/API Server/README.md index 20a8d92..aa3d72b 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -191,7 +191,7 @@ 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 +PUT /api/cars/{id}/view # which tabs + Information rows this car shows, and their order 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 eb10d15..60628b5 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -258,11 +258,11 @@ var hideableCarFields = map[string]bool{ "vin": true, "fuelType": true, "buildDate": true, "firstRegistration": true, } -// normalizeHidden validates a switched-off set against the keys that exist. -// Unknown keys are rejected rather than ignored: they can only come from a stale -// or wrong client, and dropping them silently would hide the mistake while the -// tab or field stayed visible. -func normalizeHidden(in []string, allowed map[string]bool, what string) ([]string, error) { +// normalizeKeys validates a list of tab or field keys against the keys that +// exist, trimming blanks and duplicates. Unknown keys are rejected rather than +// ignored: they can only come from a stale or wrong client, and dropping them +// silently would hide the mistake while the page carried on as before. +func normalizeKeys(in []string, allowed map[string]bool, what string) ([]string, error) { out := make([]string, 0, len(in)) seen := make(map[string]bool, len(in)) for _, key := range in { @@ -271,7 +271,7 @@ func normalizeHidden(in []string, allowed map[string]bool, what string) ([]strin continue } if !allowed[key] { - return nil, fmt.Errorf("%q is not a car %s that can be hidden", key, what) + return nil, fmt.Errorf("%q is not a car %s", key, what) } seen[key] = true out = append(out, key) @@ -279,17 +279,19 @@ func normalizeHidden(in []string, allowed map[string]bool, what string) ([]strin return out, nil } -// PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, and -// which rows of the Information tab. Body: {hiddenTabs?: [...], hiddenFields?: -// [...]}; only the sets present are written, so a client can update one without -// knowing the other. 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. +// PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, which +// rows of the Information tab, and the order those rows are laid out in. Body: +// {hiddenTabs?: [...], hiddenFields?: [...], fieldOrder?: [...]}; only the lists +// present are written, so a client can rearrange the rows without resending what +// is hidden. 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. func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) { var in struct { HiddenTabs *[]string `json:"hiddenTabs"` HiddenFields *[]string `json:"hiddenFields"` + FieldOrder *[]string `json:"fieldOrder"` } if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -307,7 +309,7 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) { payload := map[string]any{} if in.HiddenTabs != nil { - tabs, err := normalizeHidden(*in.HiddenTabs, hideableCarTabs, "tab") + tabs, err := normalizeKeys(*in.HiddenTabs, hideableCarTabs, "tab") if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -315,13 +317,25 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) { payload["hidden_tabs"] = tabs } if in.HiddenFields != nil { - fields, err := normalizeHidden(*in.HiddenFields, hideableCarFields, "field") + fields, err := normalizeKeys(*in.HiddenFields, hideableCarFields, "field") if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload["hidden_fields"] = fields } + 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 + // rows it leaves out follow the arranged ones, which is also what makes a + // row added in a later release land at the end instead of the middle. + order, err := normalizeKeys(*in.FieldOrder, hideableCarFields, "field") + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload["field_order"] = order + } if len(payload) == 0 { writeError(w, http.StatusBadRequest, "no changes provided") return diff --git a/API Server/internal/api/cartabs_test.go b/API Server/internal/api/cartabs_test.go index c0eef55..4f9a7d3 100644 --- a/API Server/internal/api/cartabs_test.go +++ b/API Server/internal/api/cartabs_test.go @@ -2,32 +2,33 @@ package api import "testing" -// What a car's page shows — which tabs, and which rows of the Information tab — -// is stored on the car as the hidden sets, so the validation has to keep those -// sets to keys the page actually renders. Information itself stays out of the -// hideable tabs: a car with no tabs left would be a dead end. +// What a car's page shows — which tabs, which rows of the Information tab, and +// the order those rows are laid out in — is stored on the car as key lists, so +// the validation has to keep them to keys the page actually renders. +// Information itself stays out of the hideable tabs: a car with no tabs left +// would be a dead end. func TestNormalizeHiddenTabs(t *testing.T) { - got, err := normalizeHidden([]string{" fuel ", "parts", "fuel", ""}, hideableCarTabs, "tab") + got, err := normalizeKeys([]string{" fuel ", "parts", "fuel", ""}, hideableCarTabs, "tab") if err != nil { - t.Fatalf("normalizeHidden: %v", err) + t.Fatalf("normalizeKeys: %v", err) } want := []string{"fuel", "parts"} // trimmed, blanks dropped, deduped assertKeys(t, got, want) // Clearing the list is how a car goes back to showing everything. - if empty, err := normalizeHidden(nil, hideableCarTabs, "tab"); err != nil || len(empty) != 0 { - t.Errorf("normalizeHidden(nil) = %v, %v; want empty and no error", empty, err) + if empty, err := normalizeKeys(nil, hideableCarTabs, "tab"); err != nil || len(empty) != 0 { + t.Errorf("normalizeKeys(nil) = %v, %v; want empty and no error", empty, err) } // Information is the car itself; hiding it would leave a page with no tabs. - if _, err := normalizeHidden([]string{"info"}, hideableCarTabs, "tab"); err == nil { - t.Error("normalizeHidden allowed hiding the info tab, want an error") + if _, err := normalizeKeys([]string{"info"}, hideableCarTabs, "tab"); err == nil { + t.Error("normalizeKeys allowed hiding the info tab, want an error") } // A key from a stale or wrong client is an error, not something to drop // quietly while the tab stays visible. - if _, err := normalizeHidden([]string{"fuel", "nonsense"}, hideableCarTabs, "tab"); err == nil { - t.Error("normalizeHidden accepted an unknown tab, want an error") + if _, err := normalizeKeys([]string{"fuel", "nonsense"}, hideableCarTabs, "tab"); err == nil { + t.Error("normalizeKeys accepted an unknown tab, want an error") } // The hideable set is the contract the web app's HIDEABLE_TABS mirrors: @@ -43,18 +44,18 @@ func TestNormalizeHiddenTabs(t *testing.T) { } func TestNormalizeHiddenFields(t *testing.T) { - got, err := normalizeHidden([]string{"vin", " differentialOil ", "vin"}, hideableCarFields, "field") + got, err := normalizeKeys([]string{"vin", " differentialOil ", "vin"}, hideableCarFields, "field") if err != nil { - t.Fatalf("normalizeHidden: %v", err) + t.Fatalf("normalizeKeys: %v", err) } assertKeys(t, got, []string{"vin", "differentialOil"}) - if _, err := normalizeHidden([]string{"oilSpec", "nonsense"}, hideableCarFields, "field"); err == nil { - t.Error("normalizeHidden accepted an unknown field, want an error") + if _, err := normalizeKeys([]string{"oilSpec", "nonsense"}, hideableCarFields, "field"); err == nil { + t.Error("normalizeKeys accepted an unknown field, want an error") } // A tab key is not a field key — the two sets are validated separately. - if _, err := normalizeHidden([]string{"fuel"}, hideableCarFields, "field"); err == nil { - t.Error("normalizeHidden accepted a tab key as a field, want an error") + if _, err := normalizeKeys([]string{"fuel"}, hideableCarFields, "field"); err == nil { + t.Error("normalizeKeys accepted a tab key as a field, want an error") } // Every Information row the web app renders must be hideable; unlike the @@ -73,6 +74,31 @@ func TestNormalizeHiddenFields(t *testing.T) { } } +// 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. +func TestNormalizeFieldOrder(t *testing.T) { + got, err := normalizeKeys([]string{"vin", "odometer", "oilSpec"}, hideableCarFields, "field") + if err != nil { + t.Fatalf("normalizeKeys: %v", err) + } + assertKeys(t, got, []string{"vin", "odometer", "oilSpec"}) + + // A key repeated by a client that lost track keeps its first position; a + // second entry for the same row would put it in two places at once. + got, err = normalizeKeys([]string{"vin", "odometer", "vin"}, hideableCarFields, "field") + if err != nil { + t.Fatalf("normalizeKeys: %v", err) + } + assertKeys(t, got, []string{"vin", "odometer"}) + + // A partial arrangement is fine — the rows it leaves out follow the arranged + // ones — but an invented row is still an error. + if _, err := normalizeKeys([]string{"vin", "nonsense"}, hideableCarFields, "field"); err == nil { + t.Error("normalizeKeys accepted an unknown field in an arrangement, want an error") + } +} + func assertKeys(t *testing.T, got, want []string) { t.Helper() if len(got) != len(want) { diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index 146ee0e..b269113 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -68,11 +68,12 @@ type carRecord struct { Created string `json:"created"` Updated string `json:"updated"` - // Switched-off tabs and Information fields. Raw because PocketBase hands back - // whatever a json field holds — null on a car nobody has configured — which - // is not a []string. + // Switched-off tabs and Information fields, plus the arrangement of the + // Information rows. 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"` + FieldOrder json.RawMessage `json:"field_order"` } func (rec carRecord) toModel() models.Car { @@ -101,6 +102,7 @@ func (rec carRecord) toModel() models.Car { ProviderVehicleID: rec.ProviderVehicleID, HiddenTabs: decodeStringList(rec.HiddenTabs), HiddenFields: decodeStringList(rec.HiddenFields), + FieldOrder: decodeStringList(rec.FieldOrder), Owner: rec.Owner, Created: rec.Created, Updated: rec.Updated, diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 39c630e..4582fb9 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -46,6 +46,9 @@ 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. Empty + // means the page's own default order. + fJSON("field_order", 2000), // Owner of this car. Non-cascading: deleting a user must not wipe their cars. fRelation("owner", "users", false, false), }, diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 0185b94..01dfab7 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -75,6 +75,14 @@ type Car struct { HiddenTabs []string `json:"hiddenTabs"` HiddenFields []string `json:"hiddenFields"` + // FieldOrder is the arrangement of the Information rows, as the field keys in + // the order they are laid out. Also a property of the car, and it covers the + // hidden rows too, so a row switched back on returns to where it was. Empty + // on a car nobody has rearranged, which means the page's own default order; + // a key it doesn't mention — a row added in a later release — follows the + // arranged ones rather than appearing in the middle. + FieldOrder []string `json:"fieldOrder"` + // 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). diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 3e4c1e1..2fd8d59 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -279,6 +279,10 @@ 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. + F.json("field_order", 2000), // Owner of this car. Non-cascading on purpose: deleting a user must not // wipe their cars (account deletion in me.go intentionally leaves cars). // required:false at the DB level — the API always sets owner on create and diff --git a/Web App/README.md b/Web App/README.md index b5003cf..60b50b5 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -100,6 +100,10 @@ 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. +- **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 + native drag events as the garage, so also pointer-only. - **Car detail** — all car spec fields (engine / transmission / differential oil, brake fluid, coolant, VIN, fuel type, …) plus tabbed histories, each with an optional file attachment and add/edit/delete gated by your access level: diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 25cd125..f7278d8 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -281,7 +281,8 @@ "subtitle": "Vælg hvilke afsnit og oplysninger denne bils side viser. Det gælder alle, bilen er delt med.", "tabsHeading": "Faner", "fieldsHeading": "Oplysninger", - "alwaysOn": "{tab} er altid tilgængelig." + "alwaysOn": "{tab} er altid tilgængelig.", + "fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge." }, "provider": { @@ -331,6 +332,7 @@ "info": { "allHidden": "Alle felter er slået fra for denne bil.", + "dragHint": "Træk et felt for at ændre rækkefølgen af bilens oplysninger.", "oilSpec": "Motorolie-specifikation", "transmissionOil": "Gearolie", "differentialOil": "Differentialeolie", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index c178320..09082d4 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -356,7 +356,8 @@ "subtitle": "Pick the sections and details this car's page shows. It applies to everyone the car is shared with.", "tabsHeading": "Tabs", "fieldsHeading": "Information fields", - "alwaysOn": "{tab} is always available." + "alwaysOn": "{tab} is always available.", + "fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in." }, "provider": { @@ -406,6 +407,7 @@ "info": { "allHidden": "Every field is switched off for this car.", + "dragHint": "Drag a field to rearrange this car's information.", "oilSpec": "Engine oil spec", "transmissionOil": "Transmission oil", "differentialOil": "Differential oil", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index bd1785c..fe695b9 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -285,7 +285,8 @@ "subtitle": "Wybierz sekcje i szczegóły widoczne na stronie tego samochodu. Dotyczy wszystkich, którym go udostępniono.", "tabsHeading": "Zakładki", "fieldsHeading": "Pola informacji", - "alwaysOn": "Zakładka {tab} jest zawsze dostępna." + "alwaysOn": "Zakładka {tab} jest zawsze dostępna.", + "fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność." }, "provider": { @@ -335,6 +336,7 @@ "info": { "allHidden": "Wszystkie pola są wyłączone dla tego samochodu.", + "dragHint": "Przeciągnij pole, aby zmienić układ informacji o tym samochodzie.", "oilSpec": "Specyfikacja oleju silnikowego", "transmissionOil": "Olej przekładniowy", "differentialOil": "Olej mostu napędowego", diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index 4261f93..0d9a0d0 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -144,8 +144,8 @@ const showViewPicker = ref(false); const HIDEABLE_TABS = [ "provider", "services", "technical", "maintenance", "fuel", "documents", "parts", "reminders", ]; -// The Information rows, in the order they are laid out. Keys mirror -// hideableCarFields in the API's cars.go — the server rejects anything else. +// The Information rows, in their default order. Keys mirror hideableCarFields +// in the API's cars.go — the server rejects anything else. const INFO_FIELD_KEYS = [ "oilSpec", "transmissionOil", "differentialOil", "brakeFluid", "coolant", "odometer", "serviceInterval", "nextDue", "registrationPlate", @@ -158,7 +158,7 @@ const viewError = ref(""); function openViewPicker() { tabDraft.value = HIDEABLE_TABS.filter((key) => !hiddenTabs.value.includes(key)); - fieldDraft.value = INFO_FIELD_KEYS.filter((key) => !hiddenFields.value.includes(key)); + fieldDraft.value = fieldKeys.value.filter((key) => !hiddenFields.value.includes(key)); viewError.value = ""; showViewPicker.value = true; } @@ -202,6 +202,82 @@ function infoFieldLabel(key) { return t(`car.info.${key}`); } +// --- The arrangement of the Information rows --- +// +// The full order of all 14 keys, hidden ones included, so a row switched back on +// returns to where it was rather than to the end. Kept as its own ref rather +// than read off the car, because a drag rearranges it live and only saves on +// drop. Rebuilt whenever the car is (re)loaded. +const fieldKeys = ref([...INFO_FIELD_KEYS]); +watch( + () => car.value?.fieldOrder, + (order) => { + const arranged = []; + for (const key of order || []) { + if (INFO_FIELD_KEYS.includes(key) && !arranged.includes(key)) arranged.push(key); + } + // Anything the stored arrangement doesn't mention — a row added in a later + // release — follows the arranged ones, so it shows up at the end rather than + // in the middle of somebody's layout. Matches what the garage does. + fieldKeys.value = [...arranged, ...INFO_FIELD_KEYS.filter((k) => !arranged.includes(k))]; + }, + { immediate: true } +); + +// Dragging a row, on the same native drag events as the garage — hand-rolled +// rather than a drag library, which does mean it is pointer-only, as touch +// browsers don't fire these. Needs write access, and there is nothing to +// rearrange with a single row showing. +const canArrangeFields = computed(() => canWrite.value && infoFields.value.length > 1); +const dragField = ref(""); // row being dragged +const dropField = ref(""); // row it is currently hovering over +const fieldOrderError = ref(""); +let fieldsMoved = false; // the grid changed during this drag and isn't saved yet + +function onFieldDragStart(key, e) { + dragField.value = key; + fieldsMoved = false; + e.dataTransfer.effectAllowed = "move"; + // Firefox only starts a drag once something is on the transfer. + e.dataTransfer.setData("text/plain", key); +} + +// Reorder live as the pointer crosses rows, so the grid shows the arrangement +// you are about to get. dragenter fires again for every child element inside the +// same row, so the row being hovered is remembered and only a genuinely new one +// moves anything. The splice works on the full list, hidden rows included, which +// keeps a hidden row anchored between the same two visible neighbours. +function onFieldDragEnter(key) { + if (!dragField.value || key === dragField.value || dropField.value === key) return; + dropField.value = key; + const list = fieldKeys.value; + const from = list.indexOf(dragField.value); + const to = list.indexOf(key); + if (from < 0 || to < 0) return; + list.splice(to, 0, ...list.splice(from, 1)); + fieldsMoved = true; +} + +// Save whatever the grid now shows. Called from both drop and dragend: a row +// released over the gap between rows never produces a drop, and leaving that +// arrangement unsaved would quietly undo itself on the next load. +async function commitFieldOrder() { + dragField.value = ""; + dropField.value = ""; + if (!fieldsMoved) return; + fieldsMoved = false; + fieldOrderError.value = ""; + try { + const updated = await api.updateCarView(props.id, { fieldOrder: fieldKeys.value }); + car.value = { ...updated, access: car.value.access }; + } catch (e) { + // The arrangement didn't stick; say so and put the stored one back rather + // than leaving the page showing an order the server doesn't have. + fieldOrderError.value = e.message; + await load(); + } +} + // The Information rows as data, so the same list drives both the grid and the // picker and the two can't drift apart. `mono` marks the values that read as // figures rather than prose. @@ -230,7 +306,7 @@ const infoFields = computed(() => { mono: true, }, }; - return INFO_FIELD_KEYS.filter((key) => !hiddenFields.value.includes(key)).map((key) => ({ + return fieldKeys.value.filter((key) => !hiddenFields.value.includes(key)).map((key) => ({ key, label: infoFieldLabel(key), ...values[key], @@ -647,17 +723,36 @@ onMounted(load); @car-updated="onCarUpdated" /> - +
+

{{ fieldOrderError }}

{{ t("car.info.allHidden") }}

-
+
{{ f.label }}
{{ f.text }}
+

{{ t("car.info.dragHint") }}

@@ -1204,7 +1299,7 @@ onMounted(load);

{{ t("car.viewPicker.fieldsHeading") }}

+

{{ t("car.viewPicker.fieldsOrderHint") }}

{{ viewError }}