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:
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user