This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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),

View File

@@ -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 {

View File

@@ -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 &amp; 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' : ''">