From 8853cdd0c6c63fb8e61a06a4224977507fc3a4a0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 23 Apr 2026 08:00:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(plating):=20tree=20editor=20=E2=80=94=20mo?= =?UTF-8?q?ve=20up/down=20buttons=20+=20import=20from=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../controllers/recipe_controller.py | 127 ++++++++++++++++++ .../static/src/js/recipe_tree_editor.js | 74 ++++++++++ .../static/src/scss/recipe_tree_editor.scss | 55 ++++++++ .../static/src/xml/recipe_tree_editor.xml | 42 ++++++ 5 files changed, 299 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 0e16bd44..c30e4970 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating/controllers/recipe_controller.py b/fusion_plating/fusion_plating/controllers/recipe_controller.py index 131ed2a8..d8a77e75 100644 --- a/fusion_plating/fusion_plating/controllers/recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/recipe_controller.py @@ -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 # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js index 11041478..31fa09d9 100644 --- a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js +++ b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js @@ -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() { diff --git a/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss b/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss index 070ad01b..083e4cc0 100644 --- a/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss +++ b/fusion_plating/fusion_plating/static/src/scss/recipe_tree_editor.scss @@ -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); } } diff --git a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml index 83067eb6..b8426c1f 100644 --- a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml @@ -66,6 +66,18 @@ t-esc="getNodeTypeMeta(node.node_type).label"/> + + + + +