fix(simple-editor): HTML in chatter + library form + expand per-step inline edit
Three fixes from user feedback:
1. Chatter posting raw HTML
_AUDIT_BODY in migration 19.0.18.8.0 was a plain str with <p>
tags. message_post escaped it for safety, so the chatter pill
rendered '<p><strong>...</strong></p>' literally to the recipe
author. Wrapped in markupsafe.Markup so Odoo recognises it as
safe HTML. Going forward: ANY message_post body containing HTML
tags MUST be wrapped in Markup() — most callers already do this,
the migration script was the outlier.
2. Library template editor showed raw <p> tags
onOpenLibraryEdit was JSON-cloning the payload directly without
running description through the existing _htmlToText helper that
the per-step editor uses. Added the conversion. Save path
(onSaveLibraryEditor + library_save) already wraps via
_textToHtml so storage stays HTML-compatible.
3. Per-step inline form was missing critical fields — user had to
delete + re-add a step to change Type/workflow trigger/parallel/signoff
onToggleEdit now also captures default_kind, triggers_workflow_state_id,
parallel_start, requires_signoff into the edit state. onSaveStep
sends them in the write vals. Added _fpResetStepEdit helper to
keep open/cancel/save reset paths in sync.
New per-step form has:
* Step Type (Default Kind) dropdown — drives workflow milestone
triggers + step-kind routing (e.g. contract_review opens QA-005)
* Triggers Workflow State dropdown (Sub 14) — per-step override
* Parallel Start checkbox (Sub 13)
* Require QA Sign-off checkbox
step_write controller endpoint also gained a field whitelist —
was previously accepting any vals dict from the client (security
hole + opaque to maintainers).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.12.3',
|
'version': '19.0.18.12.4',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'work_center_id': step.work_center_id.id if step.work_center_id else False,
|
'work_center_id': step.work_center_id.id if step.work_center_id else False,
|
||||||
'source_template_id': step.source_template_id.id or False,
|
'source_template_id': step.source_template_id.id or False,
|
||||||
'collect_measurements': bool(step.collect_measurements),
|
'collect_measurements': bool(step.collect_measurements),
|
||||||
|
# Sub 13 — per-step opt-out of the sequential gate
|
||||||
|
'parallel_start': bool(step.parallel_start),
|
||||||
|
# Sub 14 — workflow milestone trigger override
|
||||||
|
'triggers_workflow_state_id': (
|
||||||
|
step.triggers_workflow_state_id.id
|
||||||
|
if 'triggers_workflow_state_id' in step._fields
|
||||||
|
and step.triggers_workflow_state_id
|
||||||
|
else False
|
||||||
|
),
|
||||||
'measurements_badge_text': badge_text,
|
'measurements_badge_text': badge_text,
|
||||||
'measurements_badge_class': badge_class,
|
'measurements_badge_class': badge_class,
|
||||||
'inputs': [
|
'inputs': [
|
||||||
@@ -437,8 +446,29 @@ class SimpleRecipeController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
|
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
|
||||||
def step_write(self, node_id, vals):
|
def step_write(self, node_id, vals):
|
||||||
node = request.env['fusion.plating.process.node'].browse(node_id)
|
"""Update fields on an existing recipe step (operation node).
|
||||||
node.write(vals)
|
|
||||||
|
Whitelisted to the fields the inline edit panel actually surfaces
|
||||||
|
— never trust client-provided node_type / parent_id / etc.
|
||||||
|
"""
|
||||||
|
node = request.env['fusion.plating.process.node'].browse(int(node_id))
|
||||||
|
if not node.exists():
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
node.check_access('write')
|
||||||
|
allowed = {
|
||||||
|
'name', 'description', 'icon',
|
||||||
|
'default_kind',
|
||||||
|
'requires_signoff', 'requires_predecessor_done',
|
||||||
|
'parallel_start', # Sub 13
|
||||||
|
'triggers_workflow_state_id', # Sub 14
|
||||||
|
'requires_rack_assignment',
|
||||||
|
'requires_transition_form',
|
||||||
|
'estimated_duration',
|
||||||
|
'collect_measurements',
|
||||||
|
}
|
||||||
|
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||||||
|
if clean:
|
||||||
|
node.write(clean)
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|
||||||
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')
|
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ have a paper trail.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_AUDIT_BODY = (
|
# Wrapped in Markup so Odoo's message_post recognises it as safe HTML
|
||||||
|
# instead of escaping the tags. Without Markup, the chatter pill renders
|
||||||
|
# the raw "<p><strong>...</strong></p>" string literally to the operator.
|
||||||
|
_AUDIT_BODY = Markup(
|
||||||
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
|
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
|
||||||
'<p>Step nodes that were direct children of this recipe (Simple '
|
'<p>Step nodes that were direct children of this recipe (Simple '
|
||||||
'Editor authoring) have been promoted to operation nodes so they '
|
'Editor authoring) have been promoted to operation nodes so they '
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
editingStepId: null,
|
editingStepId: null,
|
||||||
editName: "",
|
editName: "",
|
||||||
editInstructions: "",
|
editInstructions: "",
|
||||||
|
// Sub 14 + Sub 13 — additional per-step settings that the
|
||||||
|
// user can change inline without delete + re-add.
|
||||||
|
editDefaultKind: "",
|
||||||
|
editTriggersWorkflowStateId: false,
|
||||||
|
editParallelStart: false,
|
||||||
|
editRequiresSignoff: false,
|
||||||
// Inline library form — open when authoring or editing a
|
// Inline library form — open when authoring or editing a
|
||||||
// library template directly from the right pane. null =
|
// library template directly from the right pane. null =
|
||||||
// closed; otherwise carries the template payload.
|
// closed; otherwise carries the template payload.
|
||||||
@@ -287,6 +293,13 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
// we want to be able to mutate this.state.libraryEditor.* in
|
// we want to be able to mutate this.state.libraryEditor.* in
|
||||||
// place without triggering library list re-renders.
|
// place without triggering library list re-renders.
|
||||||
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
|
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
|
||||||
|
// description is fields.Html on the server → strip <p> tags
|
||||||
|
// for clean display in the textarea (operators don't want
|
||||||
|
// to see raw HTML markup). Saved back via _textToHtml on
|
||||||
|
// onSaveLibraryEditor below.
|
||||||
|
this.state.libraryEditor.description = this._htmlToText(
|
||||||
|
this.state.libraryEditor.description || ""
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
_t("Could not load library template — it may have been deleted."),
|
_t("Could not load library template — it may have been deleted."),
|
||||||
@@ -560,18 +573,35 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
* Save discards changes — operator-style "I clicked the wrong row"
|
* Save discards changes — operator-style "I clicked the wrong row"
|
||||||
* shouldn't write garbage to the recipe.
|
* shouldn't write garbage to the recipe.
|
||||||
*/
|
*/
|
||||||
onToggleEdit(stepId) {
|
async onToggleEdit(stepId) {
|
||||||
if (this.state.editingStepId === stepId) {
|
if (this.state.editingStepId === stepId) {
|
||||||
this.state.editingStepId = null;
|
this._fpResetStepEdit();
|
||||||
this.state.editName = "";
|
|
||||||
this.state.editInstructions = "";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const step = this.state.steps.find((s) => s.id === stepId);
|
const step = this.state.steps.find((s) => s.id === stepId);
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
|
// Sub 14 — make sure the workflow-state catalog is cached so
|
||||||
|
// the dropdown in the inline form has options to render.
|
||||||
|
await this._fpEnsureWorkflowStatesLoaded();
|
||||||
this.state.editingStepId = stepId;
|
this.state.editingStepId = stepId;
|
||||||
this.state.editName = step.name || "";
|
this.state.editName = step.name || "";
|
||||||
this.state.editInstructions = this._htmlToText(step.description || "");
|
this.state.editInstructions = this._htmlToText(step.description || "");
|
||||||
|
// Settings the user can now change WITHOUT delete + re-add.
|
||||||
|
this.state.editDefaultKind = step.default_kind || "";
|
||||||
|
this.state.editTriggersWorkflowStateId =
|
||||||
|
step.triggers_workflow_state_id || false;
|
||||||
|
this.state.editParallelStart = !!step.parallel_start;
|
||||||
|
this.state.editRequiresSignoff = !!step.requires_signoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fpResetStepEdit() {
|
||||||
|
this.state.editingStepId = null;
|
||||||
|
this.state.editName = "";
|
||||||
|
this.state.editInstructions = "";
|
||||||
|
this.state.editDefaultKind = "";
|
||||||
|
this.state.editTriggersWorkflowStateId = false;
|
||||||
|
this.state.editParallelStart = false;
|
||||||
|
this.state.editRequiresSignoff = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onSaveStep() {
|
async onSaveStep() {
|
||||||
@@ -580,22 +610,25 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
const vals = {
|
const vals = {
|
||||||
name: this.state.editName || _t("Untitled Step"),
|
name: this.state.editName || _t("Untitled Step"),
|
||||||
description: this._textToHtml(this.state.editInstructions),
|
description: this._textToHtml(this.state.editInstructions),
|
||||||
|
// New per-step settings — user can flip these without
|
||||||
|
// deleting and re-adding the step.
|
||||||
|
default_kind: this.state.editDefaultKind || false,
|
||||||
|
triggers_workflow_state_id:
|
||||||
|
this.state.editTriggersWorkflowStateId || false,
|
||||||
|
parallel_start: !!this.state.editParallelStart,
|
||||||
|
requires_signoff: !!this.state.editRequiresSignoff,
|
||||||
};
|
};
|
||||||
await rpc("/fp/simple_recipe/step/write", {
|
await rpc("/fp/simple_recipe/step/write", {
|
||||||
node_id: stepId,
|
node_id: stepId,
|
||||||
vals: vals,
|
vals: vals,
|
||||||
});
|
});
|
||||||
this.state.editingStepId = null;
|
this._fpResetStepEdit();
|
||||||
this.state.editName = "";
|
|
||||||
this.state.editInstructions = "";
|
|
||||||
await this.loadAll();
|
await this.loadAll();
|
||||||
this.notification.add(_t("Step updated"), { type: "success" });
|
this.notification.add(_t("Step updated"), { type: "success" });
|
||||||
}
|
}
|
||||||
|
|
||||||
onCancelEdit() {
|
onCancelEdit() {
|
||||||
this.state.editingStepId = null;
|
this._fpResetStepEdit();
|
||||||
this.state.editName = "";
|
|
||||||
this.state.editInstructions = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Sub 12d — measurements config --------------------
|
// -------------------- Sub 12d — measurements config --------------------
|
||||||
|
|||||||
@@ -116,6 +116,83 @@
|
|||||||
Shown to operators when running this step at the tank. Use line breaks for separate points.
|
Shown to operators when running this step at the tank. Use line breaks for separate points.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub 14 + Sub 13 — settings the user can change
|
||||||
|
without delete + re-add. Step Type drives the
|
||||||
|
default-kind workflow trigger; the dropdown
|
||||||
|
below it lets them override per-step. -->
|
||||||
|
<div class="o_fp_edit_row" style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||||
|
<div class="o_fp_edit_field" style="flex: 1; min-width: 240px;">
|
||||||
|
<label>Step Type (Default Kind)</label>
|
||||||
|
<select class="form-select"
|
||||||
|
t-on-change="(ev) => { state.editDefaultKind = ev.target.value; }">
|
||||||
|
<option value="" t-att-selected="!state.editDefaultKind">— Generic —</option>
|
||||||
|
<option value="receiving" t-att-selected="state.editDefaultKind === 'receiving'">Receiving / Incoming Inspection</option>
|
||||||
|
<option value="contract_review" t-att-selected="state.editDefaultKind === 'contract_review'">Contract Review (QA-005)</option>
|
||||||
|
<option value="racking" t-att-selected="state.editDefaultKind === 'racking'">Racking</option>
|
||||||
|
<option value="mask" t-att-selected="state.editDefaultKind === 'mask'">Masking</option>
|
||||||
|
<option value="cleaning" t-att-selected="state.editDefaultKind === 'cleaning'">Cleaning</option>
|
||||||
|
<option value="electroclean" t-att-selected="state.editDefaultKind === 'electroclean'">Electroclean</option>
|
||||||
|
<option value="etch" t-att-selected="state.editDefaultKind === 'etch'">Etch / Activation</option>
|
||||||
|
<option value="rinse" t-att-selected="state.editDefaultKind === 'rinse'">Rinse</option>
|
||||||
|
<option value="strike" t-att-selected="state.editDefaultKind === 'strike'">Strike</option>
|
||||||
|
<option value="plate" t-att-selected="state.editDefaultKind === 'plate'">Plating</option>
|
||||||
|
<option value="replenishment" t-att-selected="state.editDefaultKind === 'replenishment'">Tank Replenishment</option>
|
||||||
|
<option value="wbf_test" t-att-selected="state.editDefaultKind === 'wbf_test'">Water Break Free Test</option>
|
||||||
|
<option value="dry" t-att-selected="state.editDefaultKind === 'dry'">Drying</option>
|
||||||
|
<option value="bake" t-att-selected="state.editDefaultKind === 'bake'">Bake</option>
|
||||||
|
<option value="demask" t-att-selected="state.editDefaultKind === 'demask'">De-Masking</option>
|
||||||
|
<option value="derack" t-att-selected="state.editDefaultKind === 'derack'">De-Racking</option>
|
||||||
|
<option value="inspect" t-att-selected="state.editDefaultKind === 'inspect'">Inspection</option>
|
||||||
|
<option value="final_inspect" t-att-selected="state.editDefaultKind === 'final_inspect'">Final Inspection</option>
|
||||||
|
<option value="ship" t-att-selected="state.editDefaultKind === 'ship'">Shipping</option>
|
||||||
|
</select>
|
||||||
|
<p class="o_fp_edit_hint">
|
||||||
|
Drives workflow milestone triggers (e.g. <code>final_inspect</code> fires
|
||||||
|
the Inspected status) and routing (e.g. <code>contract_review</code> opens
|
||||||
|
QA-005 instead of the input wizard).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_edit_field"
|
||||||
|
style="flex: 1; min-width: 240px;"
|
||||||
|
t-if="state.workflowStates and state.workflowStates.length">
|
||||||
|
<label>Triggers Workflow State</label>
|
||||||
|
<select class="form-select"
|
||||||
|
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||||
|
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
|
||||||
|
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
|
||||||
|
<option t-att-value="ws.id"
|
||||||
|
t-att-selected="state.editTriggersWorkflowStateId === ws.id"
|
||||||
|
t-esc="ws.name"/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
<p class="o_fp_edit_hint">
|
||||||
|
Override the default-kind matching. Wins over Step Type when set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_edit_field">
|
||||||
|
<label class="me-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.editParallelStart"/>
|
||||||
|
<strong> Parallel Start</strong>
|
||||||
|
<small class="text-muted ms-2">
|
||||||
|
Lets the step start while earlier-sequence steps are still in progress
|
||||||
|
(overrides the recipe's sequential gate for this step only).
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
<br/>
|
||||||
|
<label class="me-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.editRequiresSignoff"/>
|
||||||
|
<strong> Require QA Sign-off</strong>
|
||||||
|
<small class="text-muted ms-2">
|
||||||
|
Marks this step as needing a QA signature before it can be considered done.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<!-- Sub 12d — Measurements config -->
|
<!-- Sub 12d — Measurements config -->
|
||||||
<div class="o_fp_edit_field o_fp_measurements_config">
|
<div class="o_fp_edit_field o_fp_measurements_config">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
Reference in New Issue
Block a user