changes
This commit is contained in:
@@ -13,6 +13,7 @@ import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
|
||||
export class FpSimpleRecipeEditor extends Component {
|
||||
@@ -37,6 +38,12 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
dragOverIndex: null, // 0..N (insertion index)
|
||||
dragPreviewLabel: "", // shown next to the indicator line
|
||||
dragPreviewIcon: "fa-cog",
|
||||
// Inline edit panel — id of the step currently being edited
|
||||
// (null = no panel open). Mirrors live values so the textarea
|
||||
// stays controlled without RPC roundtrip on every keystroke.
|
||||
editingStepId: null,
|
||||
editName: "",
|
||||
editInstructions: "",
|
||||
});
|
||||
|
||||
this._recipeId = null;
|
||||
@@ -90,11 +97,24 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
async reorderStep(stepId, newIndex) {
|
||||
const ids = this.state.steps.map((s) => s.id);
|
||||
const oldIndex = ids.indexOf(stepId);
|
||||
if (oldIndex < 0 || oldIndex === newIndex) {
|
||||
if (oldIndex < 0) {
|
||||
return;
|
||||
}
|
||||
// dragOverIndex is the insertion point in the ORIGINAL list. Once
|
||||
// we splice the dragged item out, every position to the right of
|
||||
// oldIndex shifts left by one — so an insertion at newIndex when
|
||||
// newIndex > oldIndex must be decremented. Without this, dropping
|
||||
// right after itself moves the row one slot down instead of
|
||||
// staying put.
|
||||
let adjusted = newIndex;
|
||||
if (newIndex > oldIndex) {
|
||||
adjusted -= 1;
|
||||
}
|
||||
if (adjusted === oldIndex) {
|
||||
return;
|
||||
}
|
||||
ids.splice(oldIndex, 1);
|
||||
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
|
||||
ids.splice(Math.max(0, Math.min(adjusted, ids.length)), 0, stepId);
|
||||
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
|
||||
await this.loadAll();
|
||||
}
|
||||
@@ -199,6 +219,26 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
this.state.dragOverIndex = this.state.steps.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel-level dragover. Required so HTML5 `drop` actually fires
|
||||
* across the whole panel surface — including the gap between rows
|
||||
* (.25rem margin) and the panel padding (1rem). Without this, drops
|
||||
* on those areas are silently rejected by the browser. Row-level
|
||||
* dragover handlers still run first and set the precise index;
|
||||
* this is the safety net that keeps the most-recently-set index
|
||||
* (or end-of-list fallback) live until the user releases.
|
||||
*/
|
||||
onPanelDragOver(ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect =
|
||||
ev.dataTransfer.types.includes("application/x-fp-library")
|
||||
? "copy"
|
||||
: "move";
|
||||
if (this.state.dragOverIndex === null) {
|
||||
this.state.dragOverIndex = this.state.steps.length;
|
||||
}
|
||||
}
|
||||
|
||||
async onDrop(ev) {
|
||||
ev.preventDefault();
|
||||
const targetIndex = this.state.dragOverIndex !== null
|
||||
@@ -236,12 +276,87 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
this.state.dragPreviewIcon = "fa-cog";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- edit panel
|
||||
|
||||
/**
|
||||
* Toggle the inline edit panel for a step. Closing without explicit
|
||||
* Save discards changes — operator-style "I clicked the wrong row"
|
||||
* shouldn't write garbage to the recipe.
|
||||
*/
|
||||
onToggleEdit(stepId) {
|
||||
if (this.state.editingStepId === stepId) {
|
||||
this.state.editingStepId = null;
|
||||
this.state.editName = "";
|
||||
this.state.editInstructions = "";
|
||||
return;
|
||||
}
|
||||
const step = this.state.steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
this.state.editingStepId = stepId;
|
||||
this.state.editName = step.name || "";
|
||||
this.state.editInstructions = this._htmlToText(step.description || "");
|
||||
}
|
||||
|
||||
async onSaveStep() {
|
||||
const stepId = this.state.editingStepId;
|
||||
if (!stepId) return;
|
||||
const vals = {
|
||||
name: this.state.editName || _t("Untitled Step"),
|
||||
description: this._textToHtml(this.state.editInstructions),
|
||||
};
|
||||
await rpc("/fp/simple_recipe/step/write", {
|
||||
node_id: stepId,
|
||||
vals: vals,
|
||||
});
|
||||
this.state.editingStepId = null;
|
||||
this.state.editName = "";
|
||||
this.state.editInstructions = "";
|
||||
await this.loadAll();
|
||||
this.notification.add(_t("Step updated"), { type: "success" });
|
||||
}
|
||||
|
||||
onCancelEdit() {
|
||||
this.state.editingStepId = null;
|
||||
this.state.editName = "";
|
||||
this.state.editInstructions = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render stored HTML as plain text for the textarea. Strips tags,
|
||||
* collapses block elements to newlines. Good enough for the simple
|
||||
* editor — the tree editor handles full rich text.
|
||||
*/
|
||||
_htmlToText(html) {
|
||||
if (!html) return "";
|
||||
const tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
// Replace block elements + <br> with newlines before reading text.
|
||||
tmp.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
|
||||
tmp.querySelectorAll("p, div, li").forEach((el) => {
|
||||
el.append("\n");
|
||||
});
|
||||
return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
/** Wrap user text into safe HTML so the Html field stores cleanly. */
|
||||
_textToHtml(text) {
|
||||
if (!text) return "";
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- helpers
|
||||
|
||||
async _confirm(message) {
|
||||
return await new Promise((resolve) => {
|
||||
this.dialog.add(
|
||||
"web.ConfirmationDialog",
|
||||
ConfirmationDialog,
|
||||
{
|
||||
body: message,
|
||||
confirm: () => resolve(true),
|
||||
|
||||
@@ -152,6 +152,12 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
background: $fp-se-drop;
|
||||
border-color: $fp-se-accent;
|
||||
}
|
||||
&.o_fp_step_row_editing {
|
||||
border-color: $fp-se-accent;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.o_fp_drag_handle {
|
||||
color: $fp-se-muted;
|
||||
@@ -163,6 +169,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
.o_fp_step_name { flex: 1; }
|
||||
.o_fp_step_has_instructions {
|
||||
color: $fp-se-accent;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.o_fp_station_badge {
|
||||
font-size: .75rem;
|
||||
color: $fp-se-muted;
|
||||
@@ -170,19 +180,64 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
padding: .125rem .5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.o_fp_step_edit,
|
||||
.o_fp_step_remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $fp-se-muted;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity .1s;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
&:hover .o_fp_step_remove {
|
||||
.o_fp_step_edit { font-size: .9rem; }
|
||||
.o_fp_step_remove { font-size: 1.25rem; }
|
||||
&:hover .o_fp_step_edit,
|
||||
&:hover .o_fp_step_remove,
|
||||
&.o_fp_step_row_editing .o_fp_step_edit {
|
||||
opacity: 1;
|
||||
}
|
||||
.o_fp_step_edit:hover {
|
||||
color: $fp-se-accent;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_step_edit_panel {
|
||||
background: $fp-se-card;
|
||||
border: 1px solid $fp-se-accent;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: .75rem;
|
||||
margin-bottom: .25rem;
|
||||
|
||||
.o_fp_edit_field {
|
||||
margin-bottom: .75rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: .85rem;
|
||||
margin-bottom: .25rem;
|
||||
color: $fp-se-accent;
|
||||
}
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_edit_hint {
|
||||
margin: .25rem 0 0 0;
|
||||
font-size: .75rem;
|
||||
color: $fp-se-muted;
|
||||
}
|
||||
|
||||
.o_fp_edit_actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_step_dropzone {
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
|
||||
<div class="o_fp_simple_editor_body" t-if="!state.loading">
|
||||
<div class="o_fp_selected_panel"
|
||||
t-on-dragover="(ev) => this.onPanelDragOver(ev)"
|
||||
t-on-dragleave="(ev) => this.onDragLeave(ev)"
|
||||
t-on-dragend="() => this.onDragEnd()"
|
||||
t-on-drop="(ev) => this.onDrop(ev)">
|
||||
<h3>Selected (drag to reorder)</h3>
|
||||
<h3>Selected (drag to reorder, click pencil to edit)</h3>
|
||||
<div class="o_fp_steps_list">
|
||||
|
||||
<!-- Top drop indicator (insertion at index 0). Visible
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
<t t-foreach="state.steps" t-as="step" t-key="step.id">
|
||||
<div class="o_fp_step_row"
|
||||
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
|
||||
draggable="true"
|
||||
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
|
||||
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
|
||||
@@ -60,16 +62,58 @@
|
||||
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
|
||||
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
|
||||
<span class="o_fp_step_name" t-esc="step.name"/>
|
||||
<span class="o_fp_step_has_instructions"
|
||||
t-if="step.description"
|
||||
title="Has operator instructions">
|
||||
<i class="fa fa-file-text-o"/>
|
||||
</span>
|
||||
<span class="o_fp_station_badge"
|
||||
t-if="step.tank_ids and step.tank_ids.length">
|
||||
<t t-esc="step.tank_ids.length"/> stations
|
||||
</span>
|
||||
<button class="o_fp_step_edit"
|
||||
title="Edit name & instructions"
|
||||
t-on-click="() => this.onToggleEdit(step.id)">
|
||||
<i class="fa fa-pencil"/>
|
||||
</button>
|
||||
<button class="o_fp_step_remove"
|
||||
title="Remove step"
|
||||
t-on-click="() => this.onRemoveStep(step.id)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline edit panel (shown when this step is selected for editing). -->
|
||||
<div class="o_fp_step_edit_panel"
|
||||
t-if="state.editingStepId === step.id">
|
||||
<div class="o_fp_edit_field">
|
||||
<label>Step name</label>
|
||||
<input type="text" class="form-control"
|
||||
t-model="state.editName"
|
||||
placeholder="e.g. Acid Etch"/>
|
||||
</div>
|
||||
<div class="o_fp_edit_field">
|
||||
<label>Default instructions for operator</label>
|
||||
<textarea class="form-control"
|
||||
rows="5"
|
||||
t-model="state.editInstructions"
|
||||
placeholder="What the operator/employee sees on the shop floor when running this step. Plain text — line breaks are preserved."/>
|
||||
<p class="o_fp_edit_hint">
|
||||
Shown to operators when running this step at the tank. Use line breaks for separate points.
|
||||
</p>
|
||||
</div>
|
||||
<div class="o_fp_edit_actions">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.onSaveStep()">
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onCancelEdit()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
|
||||
<div class="o_fp_drop_indicator"
|
||||
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">
|
||||
|
||||
Reference in New Issue
Block a user