feat(plating): tree editor — move up/down buttons + import from recipe

Two gaps closed on the recipe tree editor based on user feedback:

1. Explicit Move Up / Move Down buttons on every non-recipe node
   row, driven by a new POST /fp/recipe/node/move_sibling endpoint.
   DnD already existed but couldn't reliably move a node above a
   sibling when the drop zone overlapped the node being dragged.
   The button-based flow sidesteps that entirely and makes "nudge
   one slot" a single click.

2. Inline "Import from recipe" toolbar that appears when the
   Import button is clicked in the header. User picks a source
   recipe from a dropdown (POST /fp/recipe/list, excludes the
   current one), toggles "Skip duplicate names", and clicks
   Import. POST /fp/recipe/node/import_children deep-copies every
   top-level child of the source under the current recipe,
   preserving the sub-tree structure. Dedupe is on by default so
   re-running the import on an already-populated recipe is a
   no-op; users who want to merge identical-named branches can
   untick the checkbox.

Controller endpoints:
- /fp/recipe/list (list recipe roots for the picker)
- /fp/recipe/node/move_sibling (swap with neighbour by direction)
- /fp/recipe/node/import_children (deep-copy subtree with dedupe)

Smoke verified on entech: 5-child recipe imported cleanly, dedupe
blocks re-import, sequence swap works.

fusion_plating → 19.0.7.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 08:00:24 -04:00
parent c76bbd85eb
commit 8853cdd0c6
5 changed files with 299 additions and 1 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.6.2.0',
'version': '19.0.7.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -136,6 +136,133 @@ class FpRecipeController(http.Controller):
_logger.exception('Recipe reorder failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# List every recipe-root for the "Import from recipe" picker
# ------------------------------------------------------------------
@http.route('/fp/recipe/list', type='jsonrpc', auth='user')
def list_recipes(self, exclude_id=None):
"""Return all recipe-roots available for the import picker.
exclude_id: optional — skip this recipe (usually the currently-
open one, so the user can't import from themselves).
"""
Node = request.env['fusion.plating.process.node']
domain = [('node_type', '=', 'recipe')]
if exclude_id:
domain.append(('id', '!=', int(exclude_id)))
recipes = Node.search(domain, order='name')
return {
'ok': True,
'recipes': [
{'id': r.id, 'name': r.name or f'Recipe #{r.id}'}
for r in recipes
],
}
# ------------------------------------------------------------------
# Move sibling up / down — explicit button-driven reorder
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/move_sibling', type='jsonrpc', auth='user')
def move_sibling(self, node_id, direction):
"""Swap this node's sequence with its immediate sibling.
direction: 'up' or 'down'. Safer than drag-and-drop for the
common "nudge one slot" case that the DnD flow can't do
reliably on long lists.
"""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'Node not found.'}
if not node.parent_id:
return {'ok': False, 'error': 'Cannot move a recipe root.'}
siblings = node.parent_id.child_ids.sorted('sequence')
idx = list(siblings.ids).index(node.id)
if direction == 'up':
if idx == 0:
return {'ok': True, 'no_move': True}
other = siblings[idx - 1]
elif direction == 'down':
if idx >= len(siblings) - 1:
return {'ok': True, 'no_move': True}
other = siblings[idx + 1]
else:
return {'ok': False, 'error': 'Invalid direction.'}
# Swap the two sequence values
a_seq, b_seq = node.sequence, other.sequence
if a_seq == b_seq:
# Sequences collided — renumber everyone cleanly, then swap
for i, s in enumerate(siblings, 1):
s.sequence = i * 10
a_seq, b_seq = node.sequence, other.sequence
node.sequence, other.sequence = b_seq, a_seq
return {'ok': True}
# ------------------------------------------------------------------
# Import children from another recipe into a target parent
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/import_children', type='jsonrpc', auth='user')
def import_children(self, source_recipe_id, target_parent_id,
dedupe_by_name=True):
"""Copy every top-level child of `source_recipe_id` under
`target_parent_id`, preserving the sub-tree structure.
dedupe_by_name: when True, skip any immediate child whose name
already exists under target_parent_id. Useful for importing a
preset without clobbering custom tweaks already made.
Returns: {ok, imported_count, skipped_count}
"""
Node = request.env['fusion.plating.process.node']
source = Node.browse(int(source_recipe_id))
target = Node.browse(int(target_parent_id))
if not source.exists() or not target.exists():
return {'ok': False, 'error': 'Source or target not found.'}
if f'/{source.id}/' in (target.parent_path or ''):
return {'ok': False, 'error': 'Target is inside source — would loop.'}
existing_names = set()
if dedupe_by_name:
existing_names = {
(c.name or '').strip().lower()
for c in target.child_ids
}
imported = 0
skipped = 0
max_seq = max((c.sequence for c in target.child_ids), default=0)
def _copy_subtree(src_node, new_parent, base_seq):
"""Deep-copy src_node under new_parent, recursing children."""
copy_vals = {
'parent_id': new_parent.id,
'sequence': base_seq,
}
# copy() picks up every other field; we only override parent + seq.
new_node = src_node.copy(copy_vals)
# Recurse — child_ids of the copy are blank (Odoo doesn't
# deep-copy one2many children by default for this model)
for i, child in enumerate(src_node.child_ids.sorted('sequence'), 1):
_copy_subtree(child, new_node, i * 10)
return new_node
for i, child in enumerate(source.child_ids.sorted('sequence'), 1):
key = (child.name or '').strip().lower()
if dedupe_by_name and key and key in existing_names:
skipped += 1
continue
max_seq += 10
_copy_subtree(child, target, max_seq)
imported += 1
if key:
existing_names.add(key)
return {
'ok': True,
'imported_count': imported,
'skipped_count': skipped,
}
# ------------------------------------------------------------------
# Move node to new parent
# ------------------------------------------------------------------

View File

@@ -115,6 +115,12 @@ export class RecipeTreeEditor extends Component {
// True when this editor instance was opened from the part-
// scoped Process Composer; drives the back-button label.
fromPart: false,
// Import-from-recipe picker (top-of-tree toolbar)
importOpen: false,
importRecipeOptions: [],
importRecipeId: null,
importDedupe: true,
importing: false,
});
this._recipeId = null;
@@ -422,6 +428,74 @@ export class RecipeTreeEditor extends Component {
this._draggedNode = null;
}
// ---- Sibling reorder (explicit up/down buttons) ------------------------
async onMoveSibling(nodeId, direction) {
try {
const result = await rpc("/fp/recipe/node/move_sibling", {
node_id: nodeId,
direction,
});
if (result && result.ok && !result.no_move) {
await this.loadTree();
}
} catch (err) {
this.notification.add(`Move failed: ${err.message}`, { type: "danger" });
}
}
// ---- Import children from another recipe --------------------------------
async toggleImportPicker() {
this.state.importOpen = !this.state.importOpen;
if (this.state.importOpen && !this.state.importRecipeOptions.length) {
try {
const result = await rpc("/fp/recipe/list", {
exclude_id: this._recipeId,
});
if (result && result.ok) {
this.state.importRecipeOptions = result.recipes;
}
} catch (err) {
this.notification.add(
`Failed to load recipes: ${err.message}`,
{ type: "danger" }
);
}
}
}
async doImport() {
if (!this.state.importRecipeId) {
this.notification.add("Pick a source recipe first.", { type: "warning" });
return;
}
if (!this._recipeId) return;
this.state.importing = true;
try {
const result = await rpc("/fp/recipe/node/import_children", {
source_recipe_id: parseInt(this.state.importRecipeId, 10),
target_parent_id: this._recipeId,
dedupe_by_name: !!this.state.importDedupe,
});
if (result && result.ok) {
this.notification.add(
`Imported ${result.imported_count} node(s), skipped ${result.skipped_count} duplicate(s).`,
{ type: "success" }
);
this.state.importOpen = false;
this.state.importRecipeId = null;
await this.loadTree();
} else {
this.notification.add(result?.error || "Import failed.", { type: "warning" });
}
} catch (err) {
this.notification.add(`Import failed: ${err.message}`, { type: "danger" });
} finally {
this.state.importing = false;
}
}
// ---- Navigation ---------------------------------------------------------
onBackToList() {

View File

@@ -145,6 +145,60 @@ $re-line-w : 2px;
border-color: color-mix(in srgb, #{$re-accent} 45%, #{$re-border});
}
}
.o_fp_re_btn_primary {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
background-color: $re-accent;
color: #ffffff;
border: 1px solid $re-accent;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
&:hover:not(:disabled) {
background-color: color-mix(in srgb, $re-accent 88%, #000);
border-color: color-mix(in srgb, $re-accent 88%, #000);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.o_fp_re_import_bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 24px;
background-color: $re-soft;
border-bottom: 1px solid $re-border;
font-size: 0.85rem;
color: $re-ink;
.o_fp_re_import_label {
margin: 0;
font-weight: 600;
}
.o_fp_re_import_select {
flex: 0 1 360px;
min-width: 240px;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid $re-border;
background-color: $re-card;
color: $re-ink;
}
.o_fp_re_import_dedupe {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
user-select: none;
input[type="checkbox"] { margin: 0; }
}
}
// -------------------------------------------------------------------------
@@ -372,6 +426,7 @@ $re-line-w : 2px;
&.o_fp_re_btn_add:hover { background-color: rgba(40, 167, 69, 0.35); }
&.o_fp_re_btn_del:hover { background-color: rgba(220, 53, 69, 0.35); }
&.o_fp_re_btn_move:hover { background-color: rgba(13, 110, 253, 0.35); }
}

View File

@@ -66,6 +66,18 @@
t-esc="getNodeTypeMeta(node.node_type).label"/>
<!-- Hover-revealed actions -->
<span class="o_fp_re_actions">
<button class="o_fp_re_btn o_fp_re_btn_move"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.onMoveSibling(node.id, 'up')"
title="Move up">
<i class="fa fa-arrow-up"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_move"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.onMoveSibling(node.id, 'down')"
title="Move down">
<i class="fa fa-arrow-down"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_add"
t-on-click.stop="() => this.startAddChild(node.id)"
title="Add child step">
@@ -167,6 +179,11 @@
</div>
</div>
<div class="o_fp_re_header_actions" t-if="state.recipe">
<button class="o_fp_re_btn_outline"
t-on-click="toggleImportPicker"
title="Import children from another recipe">
<i class="fa fa-download me-1"/>Import
</button>
<button class="o_fp_re_btn_outline"
t-on-click="onDuplicate"
title="Duplicate recipe">
@@ -180,6 +197,31 @@
</div>
</div>
<!-- ========== IMPORT TOOLBAR (inline under header) ========== -->
<div class="o_fp_re_import_bar" t-if="state.importOpen and state.recipe">
<label class="o_fp_re_import_label">Import children from:</label>
<select class="o_fp_re_import_select"
t-model="state.importRecipeId">
<option value="">— Pick a recipe —</option>
<t t-foreach="state.importRecipeOptions" t-as="r" t-key="r.id">
<option t-att-value="r.id" t-esc="r.name"/>
</t>
</select>
<label class="o_fp_re_import_dedupe">
<input type="checkbox" t-model="state.importDedupe"/>
Skip duplicate names
</label>
<button class="o_fp_re_btn_primary"
t-att-disabled="state.importing or !state.importRecipeId"
t-on-click="doImport">
<i class="fa fa-download me-1"/>
<t t-if="state.importing">Importing…</t>
<t t-else="">Import</t>
</button>
<button class="o_fp_re_btn_outline"
t-on-click="toggleImportPicker">Cancel</button>
</div>
<!-- ========== LOADING ========== -->
<div class="text-center py-5" t-if="state.loading and !state.tree">
<i class="fa fa-spinner fa-spin fa-2x"/>