Cars: drag the Information rows into the order you want
The rows on a car's Information tab now take a drag: they reorder as the
pointer crosses them and the arrangement saves on drop — or on dragend,
since a row released in the gap between rows never produces a drop and
would otherwise revert on the next load. Same native drag events as the
garage, so also pointer-only, and it needs write access.
The order belongs to the car, like the choice of which rows show at all,
so everyone it is shared with sees the same page. It is stored as the
full list of the 14 keys, hidden rows included: a row switched off and
back on returns to where it was rather than to the end. A key the stored
arrangement doesn't mention — a row added in a later release — follows
the arranged ones, the same rule the garage uses for a car added since
the last drag.
fieldOrder rides on the existing PUT /api/cars/{id}/view, which writes
only the lists it is given, so a drag never has to resend what is hidden.
A partial arrangement is accepted; an invented key is still a 400, which
is why normalizeHidden is now normalizeKeys — it validates an order as
well as a switched-off set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
049da69c83
commit
bc798dae49
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- Information -->
|
||||
<!-- Information. The rows can be dragged into any order with write access;
|
||||
the arrangement belongs to the car, like which rows show at all. -->
|
||||
<section v-else-if="activeTab === 'info'">
|
||||
<p v-if="fieldOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ fieldOrderError }}</p>
|
||||
<div class="dh-card p-6">
|
||||
<p v-if="infoFields.length === 0" class="text-sm text-muted">{{ t("car.info.allHidden") }}</p>
|
||||
<dl v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div v-for="f in infoFields" :key="f.key">
|
||||
<div
|
||||
v-for="f in infoFields"
|
||||
:key="f.key"
|
||||
:draggable="canArrangeFields"
|
||||
:title="canArrangeFields ? t('car.info.dragHint') : ''"
|
||||
class="rounded-control p-2 -m-2 transition-shadow duration-150"
|
||||
:class="[
|
||||
canArrangeFields ? 'cursor-grab hover:bg-sunken active:cursor-grabbing' : '',
|
||||
dragField === f.key ? 'opacity-50' : '',
|
||||
dropField === f.key ? 'ring-2 ring-accent' : '',
|
||||
]"
|
||||
@dragstart="onFieldDragStart(f.key, $event)"
|
||||
@dragenter.prevent="onFieldDragEnter(f.key)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="commitFieldOrder"
|
||||
@dragend="commitFieldOrder"
|
||||
>
|
||||
<dt class="eyebrow">{{ f.label }}</dt>
|
||||
<dd class="mt-0.5 font-medium text-strong" :class="f.mono ? 'data' : ''">{{ f.text }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<p v-if="canArrangeFields" class="mt-2 text-xs text-muted">{{ t("car.info.dragHint") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Service history -->
|
||||
@@ -1204,7 +1299,7 @@ onMounted(load);
|
||||
<p class="eyebrow mb-2 mt-5">{{ t("car.viewPicker.fieldsHeading") }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="key in INFO_FIELD_KEYS"
|
||||
v-for="key in fieldKeys"
|
||||
:key="key"
|
||||
class="flex items-center gap-2 text-sm font-medium text-body"
|
||||
>
|
||||
@@ -1217,6 +1312,7 @@ onMounted(load);
|
||||
<span>{{ infoFieldLabel(key) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{{ t("car.viewPicker.fieldsOrderHint") }}</p>
|
||||
|
||||
<p v-if="viewError" class="mt-3 text-sm text-danger">{{ viewError }}</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user