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