feat(sub12a): OWL Simple Recipe Editor client action

JS: FpSimpleRecipeEditor component reads recipe_id from
props.action.context (matches the existing tree editor's contract).
HTML5 native drag-drop between Library (right) and Selected (left)
panels — uses two distinct dataTransfer types (application/x-fp-step
vs application/x-fp-library) so the drop handler knows whether to
reorder or snapshot-copy.

XML: 2-column grid layout. Selected has per-row × remove (hover
reveal), drag handle, position number, icon, name, station-count
badge. Library has search input, scrollable item list with empty-
state, drag-handle items.

SCSS: tokens follow the fp_shopfloor pattern with dark-mode SCSS @if
branch (CLAUDE.md rule). 2-fr grid that collapses to single column
under 900px for tablet/mobile.

Tag: fp_simple_recipe_editor — registered via registry.category('actions').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 20:42:06 -04:00
parent a892a7b20e
commit d6cdae30ec
3 changed files with 520 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
/** @odoo-module */
/*
* Sub 12a — Simple Recipe Editor (OWL client action).
*
* Flat drag-drop alternative to the tree editor. Library on the right,
* Selected (ordered steps) on the left. Drag from library → snapshot-
* copy via /fp/simple_recipe/step/insert. Drag-reorder within Selected
* → /fp/simple_recipe/step/reorder. Same recipe data either editor.
*/
import { Component, onMounted, useState } from "@odoo/owl";
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";
export class FpSimpleRecipeEditor extends Component {
static template = "fusion_plating.FpSimpleRecipeEditor";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.state = useState({
loading: true,
recipe: null,
steps: [],
library: [],
librarySearch: "",
templateOptions: [],
selectedTemplate: "",
dragOverIndex: null,
});
this._recipeId = null;
onMounted(async () => {
const ctx = this.props.action?.context || {};
this._recipeId = ctx.recipe_id || null;
if (this._recipeId) {
await this.loadAll();
} else {
this.state.loading = false;
this.notification.add(
_t("No recipe context provided. Open this editor from a recipe form."),
{ type: "warning" }
);
}
});
}
async loadAll() {
this.state.loading = true;
const [recipeData, libraryData, templateData] = await Promise.all([
rpc("/fp/simple_recipe/load", { recipe_id: this._recipeId }),
rpc("/fp/simple_recipe/library/list", { query: "" }),
rpc("/fp/simple_recipe/template/list", {}),
]);
this.state.recipe = recipeData.recipe;
this.state.steps = recipeData.steps;
this.state.library = libraryData.templates;
this.state.templateOptions = templateData.templates;
this.state.loading = false;
}
async onSearchLibrary(ev) {
const q = ev.target.value;
this.state.librarySearch = q;
const data = await rpc("/fp/simple_recipe/library/list", { query: q });
this.state.library = data.templates;
}
async insertFromLibrary(templateId, position) {
await rpc("/fp/simple_recipe/step/insert", {
recipe_id: this._recipeId,
template_id: templateId,
position: position,
});
await this.loadAll();
this.notification.add(_t("Step added"), { type: "success" });
}
async reorderStep(stepId, newIndex) {
const ids = this.state.steps.map((s) => s.id);
const oldIndex = ids.indexOf(stepId);
if (oldIndex < 0 || oldIndex === newIndex) {
return;
}
ids.splice(oldIndex, 1);
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
await this.loadAll();
}
async onRemoveStep(stepId) {
const proceed = await this._confirm(
_t("Remove this step from the recipe?")
);
if (!proceed) {
return;
}
await rpc("/fp/simple_recipe/step/remove", { node_id: stepId });
await this.loadAll();
}
async onAddInlineStep() {
await rpc("/fp/simple_recipe/step/insert", {
recipe_id: this._recipeId,
template_id: false,
position: 99,
vals: { name: "New Step" },
});
await this.loadAll();
}
async onImportTemplate() {
if (!this.state.selectedTemplate) {
return;
}
let proceed = true;
if (this.state.steps.length > 0) {
proceed = await this._confirm(
_t("This recipe already has steps. Import will append. Continue?")
);
}
if (!proceed) {
return;
}
const result = await rpc("/fp/simple_recipe/template/import", {
source_recipe_id: parseInt(this.state.selectedTemplate, 10),
target_recipe_id: this._recipeId,
});
this.notification.add(
_t("Imported %s steps", result.imported_count),
{ type: "success" }
);
await this.loadAll();
}
openInTreeEditor() {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_recipe_tree_editor",
name: this.state.recipe?.name || _t("Recipe"),
context: { recipe_id: this._recipeId },
});
}
// --------------------------------------------------------- drag & drop
onSelectedDragStart(stepId, ev) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("application/x-fp-step", String(stepId));
ev.dataTransfer.setData("text/plain", String(stepId));
}
onLibraryDragStart(templateId, ev) {
ev.dataTransfer.effectAllowed = "copy";
ev.dataTransfer.setData("application/x-fp-library", String(templateId));
ev.dataTransfer.setData("text/plain", "library");
}
onDragOver(index, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
this.state.dragOverIndex = index;
}
async onDrop(targetIndex, ev) {
ev.preventDefault();
const fromLibrary = ev.dataTransfer.getData("application/x-fp-library");
if (fromLibrary) {
await this.insertFromLibrary(parseInt(fromLibrary, 10), targetIndex);
} else {
const fromStep = ev.dataTransfer.getData("application/x-fp-step");
const draggedId = parseInt(fromStep, 10);
if (draggedId) {
await this.reorderStep(draggedId, targetIndex);
}
}
this.state.dragOverIndex = null;
}
onDragLeave() {
this.state.dragOverIndex = null;
}
// --------------------------------------------------------------- helpers
async _confirm(message) {
return await new Promise((resolve) => {
this.dialog.add(
"web.ConfirmationDialog",
{
body: message,
confirm: () => resolve(true),
cancel: () => resolve(false),
},
{ onClose: () => resolve(false) }
);
});
}
}
registry.category("actions").add("fp_simple_recipe_editor", FpSimpleRecipeEditor);

View File

@@ -0,0 +1,203 @@
// Sub 12a — Simple Recipe Editor styling.
//
// Tokens follow the existing fp_shopfloor pattern (CSS custom props
// with hex fallbacks; dark-mode aware via $o-webclient-color-scheme
// SCSS @if branch — see fusion_plating CLAUDE.md for the rule).
$o-webclient-color-scheme: bright !default;
$_fp_se_page_hex: #f3f4f6;
$_fp_se_card_hex: #ffffff;
$_fp_se_border_hex: #d8dadd;
$_fp_se_accent_hex: #2e7d6b;
$_fp_se_muted_hex: #6b7280;
$_fp_se_drop_hex: #e8f5f0;
@if $o-webclient-color-scheme == dark {
$_fp_se_page_hex: #1a1d21 !global;
$_fp_se_card_hex: #22262d !global;
$_fp_se_border_hex: #3a3f47 !global;
$_fp_se_drop_hex: #1f3a33 !global;
}
$fp-se-page: var(--fp-page-bg, #{$_fp_se_page_hex});
$fp-se-card: var(--fp-card-bg, #{$_fp_se_card_hex});
$fp-se-border: var(--fp-border-color, #{$_fp_se_border_hex});
$fp-se-accent: var(--fp-accent, #{$_fp_se_accent_hex});
$fp-se-muted: var(--fp-muted, #{$_fp_se_muted_hex});
$fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
.o_fp_simple_editor {
background: $fp-se-page;
height: 100%;
overflow: auto;
padding: 1rem;
.o_fp_simple_editor_header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
h2 {
margin: 0;
flex: 1;
color: $fp-se-accent;
}
.o_fp_simple_editor_actions {
display: flex;
gap: .5rem;
}
}
.o_fp_simple_editor_meta {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
.o_fp_import_row {
display: flex;
align-items: center;
gap: .75rem;
label { font-weight: 500; margin: 0; min-width: 14rem; }
select { flex: 1; max-width: 30rem; }
}
}
.o_fp_simple_editor_body {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
}
.o_fp_selected_panel,
.o_fp_library_panel {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 4px;
padding: 1rem;
h3 {
margin: 0 0 .75rem 0;
font-size: 1rem;
color: $fp-se-accent;
}
}
.o_fp_step_row {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-se-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-se-card;
cursor: grab;
&.o_fp_drag_over {
background: $fp-se-drop;
border-color: $fp-se-accent;
}
.o_fp_drag_handle {
color: $fp-se-muted;
cursor: grab;
user-select: none;
}
.o_fp_step_position {
font-weight: 600;
min-width: 1.5rem;
}
.o_fp_step_name { flex: 1; }
.o_fp_station_badge {
font-size: .75rem;
color: $fp-se-muted;
background: $fp-se-page;
padding: .125rem .5rem;
border-radius: 999px;
}
.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 {
opacity: 1;
}
}
.o_fp_step_dropzone {
border: 2px dashed $fp-se-border;
border-radius: 4px;
padding: 1rem;
text-align: center;
color: $fp-se-muted;
margin-top: .5rem;
&.o_fp_drag_over,
&:hover {
border-color: $fp-se-accent;
background: $fp-se-drop;
}
}
.o_fp_inline_add {
margin-top: .75rem;
}
.o_fp_library_list {
margin-top: .5rem;
max-height: 65vh;
overflow: auto;
}
.o_fp_library_item {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-se-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-se-card;
cursor: grab;
user-select: none;
.o_fp_library_name { flex: 1; }
.o_fp_library_meta {
font-size: .75rem;
color: $fp-se-muted;
}
&:hover {
border-color: $fp-se-accent;
}
}
.o_fp_library_empty {
color: $fp-se-muted;
font-style: italic;
padding: 1rem;
text-align: center;
}
.o_fp_loading {
padding: 2rem;
text-align: center;
color: $fp-se-muted;
}

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating.FpSimpleRecipeEditor">
<div class="o_fp_simple_editor">
<div class="o_fp_simple_editor_header">
<h2 t-if="state.recipe">
Recipe: <span t-esc="state.recipe.name"/>
</h2>
<div class="o_fp_simple_editor_actions">
<button class="btn btn-secondary" t-on-click="openInTreeEditor">
Open in Tree Editor
</button>
</div>
</div>
<div class="o_fp_simple_editor_meta" t-if="state.recipe">
<div class="o_fp_import_row">
<label>Import starter from template:</label>
<select t-model="state.selectedTemplate">
<option value="">— Select template —</option>
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id">
<t t-esc="tpl.name"/> (<t t-esc="tpl.step_count"/> steps)
</option>
</t>
</select>
<button class="btn btn-primary" t-on-click="onImportTemplate"
t-att-disabled="!state.selectedTemplate">
Import
</button>
</div>
</div>
<div class="o_fp_simple_editor_body" t-if="!state.loading">
<div class="o_fp_selected_panel">
<h3>Selected (drag to reorder)</h3>
<div class="o_fp_steps_list">
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
t-att-class="state.dragOverIndex === step_index ? 'o_fp_drag_over' : ''"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onDragOver(step_index, ev)"
t-on-dragleave="() => this.onDragLeave()"
t-on-drop="(ev) => this.onDrop(step_index, ev)">
<span class="o_fp_drag_handle"></span>
<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_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_remove"
t-on-click="() => this.onRemoveStep(step.id)">
×
</button>
</div>
</t>
<div class="o_fp_step_dropzone"
t-att-class="state.dragOverIndex === state.steps.length ? 'o_fp_drag_over' : ''"
t-on-dragover="(ev) => this.onDragOver(state.steps.length, ev)"
t-on-dragleave="() => this.onDragLeave()"
t-on-drop="(ev) => this.onDrop(state.steps.length, ev)">
Drop here to add at end
</div>
</div>
<button class="btn btn-secondary o_fp_inline_add"
t-on-click="onAddInlineStep">
+ Add Inline Step
</button>
</div>
<div class="o_fp_library_panel">
<h3>Step Library</h3>
<input type="text" class="form-control"
placeholder="Search…"
t-on-input="onSearchLibrary"
t-att-value="state.librarySearch"/>
<div class="o_fp_library_list">
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
<div class="o_fp_library_item"
draggable="true"
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
<span class="o_fp_library_name" t-esc="tpl.name"/>
<span class="o_fp_library_meta" t-if="tpl.station_count">
<t t-esc="tpl.station_count"/> st.
</span>
</div>
</t>
<div class="o_fp_library_empty" t-if="!state.library.length">
No library entries match your search.
</div>
</div>
</div>
</div>
<div t-if="state.loading" class="o_fp_loading">
Loading…
</div>
</div>
</t>
</templates>