diff --git a/freecad/schema_form.py b/freecad/schema_form.py new file mode 100644 index 0000000..4f30f84 --- /dev/null +++ b/freecad/schema_form.py @@ -0,0 +1,576 @@ +"""Schema-driven new-item dialog for Kindred Create. + +Fetches schema data from the Silo REST API and builds a dynamic Qt form +that mirrors the React ``CreateItemPane`` — category picker, property +fields grouped by domain, live part number preview, and project tagging. +""" + +import json +import urllib.error +import urllib.parse +import urllib.request + +import FreeCAD +from PySide import QtCore, QtGui, QtWidgets + +# --------------------------------------------------------------------------- +# Domain labels derived from the first character of category codes. +# --------------------------------------------------------------------------- + +_DOMAIN_LABELS = { + "F": "Fasteners", + "C": "Fluid Fittings", + "R": "Motion Components", + "S": "Structural", + "E": "Electrical", + "M": "Mechanical", + "T": "Tooling", + "A": "Assemblies", + "P": "Purchased", + "X": "Custom Fabricated", +} + +_ITEM_TYPES = ["part", "assembly", "consumable", "tool"] +_SOURCING_TYPES = ["manufactured", "purchased"] + + +# --------------------------------------------------------------------------- +# Collapsible group box +# --------------------------------------------------------------------------- + + +class _CollapsibleGroup(QtWidgets.QGroupBox): + """A QGroupBox that can be collapsed by clicking its title.""" + + def __init__(self, title: str, parent=None, collapsed=False): + super().__init__(title, parent) + self.setCheckable(True) + self.setChecked(not collapsed) + self.toggled.connect(self._on_toggled) + self._content = QtWidgets.QWidget() + self._layout = QtWidgets.QFormLayout(self._content) + self._layout.setContentsMargins(8, 4, 8, 4) + self._layout.setSpacing(6) + + outer = QtWidgets.QVBoxLayout(self) + outer.setContentsMargins(0, 4, 0, 0) + outer.addWidget(self._content) + + if collapsed: + self._content.hide() + + def form_layout(self) -> QtWidgets.QFormLayout: + return self._layout + + def _on_toggled(self, checked: bool): + self._content.setVisible(checked) + + +# --------------------------------------------------------------------------- +# Category picker (domain combo → subcategory combo) +# --------------------------------------------------------------------------- + + +class _CategoryPicker(QtWidgets.QWidget): + """Two chained combo boxes: domain group → subcategory within that group.""" + + category_changed = QtCore.Signal(str) # emits full code e.g. "F01" + + def __init__(self, categories: dict, parent=None): + super().__init__(parent) + self._categories = categories # {code: description, ...} + + # Group by first character + self._groups = {} # {prefix: [(code, desc), ...]} + for code, desc in sorted(categories.items()): + prefix = code[0] if code else "?" + self._groups.setdefault(prefix, []).append((code, desc)) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + self._domain_combo = QtWidgets.QComboBox() + self._domain_combo.addItem("-- Select domain --", "") + for prefix in sorted(self._groups.keys()): + label = _DOMAIN_LABELS.get(prefix, prefix) + count = len(self._groups[prefix]) + self._domain_combo.addItem(f"{prefix} \u2014 {label} ({count})", prefix) + self._domain_combo.currentIndexChanged.connect(self._on_domain_changed) + layout.addWidget(self._domain_combo, 1) + + self._sub_combo = QtWidgets.QComboBox() + self._sub_combo.setEnabled(False) + self._sub_combo.currentIndexChanged.connect(self._on_sub_changed) + layout.addWidget(self._sub_combo, 1) + + def selected_category(self) -> str: + return self._sub_combo.currentData() or "" + + def _on_domain_changed(self, _index: int): + prefix = self._domain_combo.currentData() + self._sub_combo.clear() + if not prefix: + self._sub_combo.setEnabled(False) + self.category_changed.emit("") + return + + self._sub_combo.setEnabled(True) + self._sub_combo.addItem("-- Select subcategory --", "") + for code, desc in self._groups.get(prefix, []): + self._sub_combo.addItem(f"{code} \u2014 {desc}", code) + + def _on_sub_changed(self, _index: int): + code = self._sub_combo.currentData() or "" + self.category_changed.emit(code) + + +# --------------------------------------------------------------------------- +# Property field factory +# --------------------------------------------------------------------------- + + +def _make_field(prop_def: dict) -> QtWidgets.QWidget: + """Create a Qt widget for a property definition. + + ``prop_def`` has keys: type, default, unit, description, required. + """ + ptype = prop_def.get("type", "string") + + if ptype == "boolean": + cb = QtWidgets.QCheckBox() + default = prop_def.get("default") + if default is True: + cb.setChecked(True) + if prop_def.get("description"): + cb.setToolTip(prop_def["description"]) + return cb + + if ptype == "number": + container = QtWidgets.QWidget() + h = QtWidgets.QHBoxLayout(container) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(4) + + spin = QtWidgets.QDoubleSpinBox() + spin.setDecimals(4) + spin.setRange(-1e9, 1e9) + spin.setSpecialValueText("") # show empty when at minimum + default = prop_def.get("default") + if default is not None and default != "": + try: + spin.setValue(float(default)) + except (ValueError, TypeError): + pass + else: + spin.clear() + if prop_def.get("description"): + spin.setToolTip(prop_def["description"]) + h.addWidget(spin, 1) + + unit = prop_def.get("unit", "") + if unit: + unit_label = QtWidgets.QLabel(unit) + unit_label.setFixedWidth(40) + h.addWidget(unit_label) + + return container + + # Default: string + le = QtWidgets.QLineEdit() + default = prop_def.get("default") + if default and isinstance(default, str): + le.setText(default) + if prop_def.get("description"): + le.setPlaceholderText(prop_def["description"]) + le.setToolTip(prop_def["description"]) + return le + + +def _read_field(widget: QtWidgets.QWidget, prop_def: dict): + """Extract the value from a property widget, type-converted.""" + ptype = prop_def.get("type", "string") + + if ptype == "boolean": + return widget.isChecked() + + if ptype == "number": + spin = widget.findChild(QtWidgets.QDoubleSpinBox) + if spin is None: + return None + text = spin.text().strip() + if not text: + return None + return spin.value() + + # string + if isinstance(widget, QtWidgets.QLineEdit): + val = widget.text().strip() + return val if val else None + return None + + +# --------------------------------------------------------------------------- +# Main form dialog +# --------------------------------------------------------------------------- + + +class SchemaFormDialog(QtWidgets.QDialog): + """Schema-driven new-item dialog. + + Fetches schema and property data from the Silo API, builds the form + dynamically, and returns the creation result on accept. + """ + + def __init__(self, client, parent=None): + super().__init__(parent) + self._client = client + self._result = None + self._prop_widgets = {} # {key: (widget, prop_def)} + self._prop_groups = [] # list of _CollapsibleGroup to clear on category change + self._categories = {} + self._projects = [] + + self.setWindowTitle("New Item") + self.setMinimumSize(600, 500) + self.resize(680, 700) + + self._load_schema_data() + self._build_ui() + + # Part number preview debounce timer + self._pn_timer = QtCore.QTimer(self) + self._pn_timer.setSingleShot(True) + self._pn_timer.setInterval(500) + self._pn_timer.timeout.connect(self._update_pn_preview) + + # -- data loading ------------------------------------------------------- + + def _load_schema_data(self): + """Fetch categories and projects from the API.""" + try: + schema = self._client.get_schema() + segments = schema.get("segments", []) + cat_segment = next((s for s in segments if s.get("name") == "category"), None) + if cat_segment and cat_segment.get("values"): + self._categories = cat_segment["values"] + except Exception as e: + FreeCAD.Console.PrintWarning(f"Schema form: failed to fetch schema: {e}\n") + + try: + self._projects = self._client.get_projects() or [] + except Exception: + self._projects = [] + + def _fetch_properties(self, category: str) -> dict: + """Fetch merged property definitions for a category.""" + from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context + + api_url = _get_api_url().rstrip("/") + url = f"{api_url}/schemas/kindred-rd/properties?category={urllib.parse.quote(category)}" + req = urllib.request.Request(url, method="GET") + req.add_header("Accept", "application/json") + for k, v in _get_auth_headers().items(): + req.add_header(k, v) + try: + resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) + data = json.loads(resp.read().decode("utf-8")) + return data.get("properties", data) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Schema form: failed to fetch properties for {category}: {e}\n" + ) + return {} + + def _generate_pn_preview(self, category: str) -> str: + """Call the server to preview the next part number.""" + from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context + + api_url = _get_api_url().rstrip("/") + url = f"{api_url}/generate-part-number" + payload = json.dumps({"schema": "kindred-rd", "category": category}).encode() + req = urllib.request.Request(url, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + for k, v in _get_auth_headers().items(): + req.add_header(k, v) + try: + resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) + data = json.loads(resp.read().decode("utf-8")) + return data.get("part_number", "") + except Exception: + return "" + + # -- UI construction ---------------------------------------------------- + + def _build_ui(self): + root = QtWidgets.QVBoxLayout(self) + root.setSpacing(8) + + # Part number preview banner + self._pn_label = QtWidgets.QLabel("Part Number: —") + self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;") + self._pn_label.setAlignment(QtCore.Qt.AlignCenter) + root.addWidget(self._pn_label) + + # Scroll area for form content + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.NoFrame) + scroll_content = QtWidgets.QWidget() + self._form_layout = QtWidgets.QVBoxLayout(scroll_content) + self._form_layout.setSpacing(8) + self._form_layout.setContentsMargins(8, 4, 8, 4) + scroll.setWidget(scroll_content) + root.addWidget(scroll, 1) + + # --- Identity section --- + identity = _CollapsibleGroup("Identity") + fl = identity.form_layout() + + self._type_combo = QtWidgets.QComboBox() + for t in _ITEM_TYPES: + self._type_combo.addItem(t.capitalize(), t) + fl.addRow("Type:", self._type_combo) + + self._desc_edit = QtWidgets.QLineEdit() + self._desc_edit.setPlaceholderText("Item description") + fl.addRow("Description:", self._desc_edit) + + self._cat_picker = _CategoryPicker(self._categories) + self._cat_picker.category_changed.connect(self._on_category_changed) + fl.addRow("Category:", self._cat_picker) + + self._form_layout.addWidget(identity) + + # --- Sourcing section --- + sourcing = _CollapsibleGroup("Sourcing", collapsed=True) + fl = sourcing.form_layout() + + self._sourcing_combo = QtWidgets.QComboBox() + for s in _SOURCING_TYPES: + self._sourcing_combo.addItem(s.capitalize(), s) + fl.addRow("Sourcing:", self._sourcing_combo) + + self._cost_spin = QtWidgets.QDoubleSpinBox() + self._cost_spin.setDecimals(2) + self._cost_spin.setRange(0, 1e9) + self._cost_spin.setPrefix("$ ") + self._cost_spin.setSpecialValueText("") + fl.addRow("Standard Cost:", self._cost_spin) + + self._sourcing_url = QtWidgets.QLineEdit() + self._sourcing_url.setPlaceholderText("https://...") + fl.addRow("Sourcing URL:", self._sourcing_url) + + self._form_layout.addWidget(sourcing) + + # --- Details section --- + details = _CollapsibleGroup("Details", collapsed=True) + fl = details.form_layout() + + self._long_desc = QtWidgets.QTextEdit() + self._long_desc.setMaximumHeight(80) + self._long_desc.setPlaceholderText("Detailed description...") + fl.addRow("Long Description:", self._long_desc) + + # Project selection + self._project_list = QtWidgets.QListWidget() + self._project_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + self._project_list.setMaximumHeight(100) + for proj in self._projects: + code = proj.get("code", "") + name = proj.get("name", "") + label = f"{code} \u2014 {name}" if name else code + item = QtWidgets.QListWidgetItem(label) + item.setData(QtCore.Qt.UserRole, code) + self._project_list.addItem(item) + if self._projects: + fl.addRow("Projects:", self._project_list) + + self._form_layout.addWidget(details) + + # --- Dynamic property groups (inserted here on category change) --- + self._prop_insert_index = self._form_layout.count() + + # Spacer + self._form_layout.addStretch() + + # --- Buttons --- + btn_layout = QtWidgets.QHBoxLayout() + btn_layout.addStretch() + + self._cancel_btn = QtWidgets.QPushButton("Cancel") + self._cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self._cancel_btn) + + self._create_btn = QtWidgets.QPushButton("Create") + self._create_btn.setEnabled(False) + self._create_btn.setDefault(True) + self._create_btn.clicked.connect(self._on_create) + btn_layout.addWidget(self._create_btn) + + root.addLayout(btn_layout) + + # -- category change ---------------------------------------------------- + + def _on_category_changed(self, category: str): + """Rebuild property groups when category selection changes.""" + self._create_btn.setEnabled(bool(category)) + + # Remove old property groups + for group in self._prop_groups: + self._form_layout.removeWidget(group) + group.deleteLater() + self._prop_groups.clear() + self._prop_widgets.clear() + + if not category: + self._pn_label.setText("Part Number: \u2014") + return + + # Trigger part number preview + self._pn_timer.start() + + # Fetch properties + all_props = self._fetch_properties(category) + if not all_props: + return + + # Separate into category-specific and common (default) properties. + # The server merges them but we can identify category-specific ones + # by checking what the schema defines for this category prefix. + prefix = category[0] + domain_label = _DOMAIN_LABELS.get(prefix, prefix) + + # We fetch the raw schema to determine which keys are category-specific. + # For now, use a heuristic: keys that are NOT in the well-known defaults + # list are category-specific. + _KNOWN_DEFAULTS = { + "manufacturer", + "manufacturer_pn", + "supplier", + "supplier_pn", + "sourcing_link", + "standard_cost", + "lead_time_days", + "minimum_order_qty", + "lifecycle_status", + "rohs_compliant", + "country_of_origin", + "notes", + } + + cat_specific = {} + common = {} + for key, pdef in sorted(all_props.items()): + if key in _KNOWN_DEFAULTS: + common[key] = pdef + else: + cat_specific[key] = pdef + + insert_pos = self._prop_insert_index + + # Category-specific properties group + if cat_specific: + group = _CollapsibleGroup(f"{domain_label} Properties") + fl = group.form_layout() + for key, pdef in cat_specific.items(): + label = key.replace("_", " ").title() + widget = _make_field(pdef) + fl.addRow(f"{label}:", widget) + self._prop_widgets[key] = (widget, pdef) + self._form_layout.insertWidget(insert_pos, group) + self._prop_groups.append(group) + insert_pos += 1 + + # Common properties group (collapsed by default) + if common: + group = _CollapsibleGroup("Common Properties", collapsed=True) + fl = group.form_layout() + for key, pdef in common.items(): + label = key.replace("_", " ").title() + widget = _make_field(pdef) + fl.addRow(f"{label}:", widget) + self._prop_widgets[key] = (widget, pdef) + self._form_layout.insertWidget(insert_pos, group) + self._prop_groups.append(group) + + def _update_pn_preview(self): + """Fetch and display the next part number preview.""" + category = self._cat_picker.selected_category() + if not category: + self._pn_label.setText("Part Number: \u2014") + return + + pn = self._generate_pn_preview(category) + if pn: + self._pn_label.setText(f"Part Number: {pn}") + else: + self._pn_label.setText(f"Part Number: {category}-????") + + # -- submission --------------------------------------------------------- + + def _collect_form_data(self) -> dict: + """Collect all form values into a dict suitable for create_item.""" + category = self._cat_picker.selected_category() + description = self._desc_edit.text().strip() + item_type = self._type_combo.currentData() + sourcing_type = self._sourcing_combo.currentData() + cost_text = self._cost_spin.text().strip().lstrip("$ ") + standard_cost = float(cost_text) if cost_text else None + sourcing_link = self._sourcing_url.text().strip() or None + long_description = self._long_desc.toPlainText().strip() or None + + # Projects + selected_projects = [] + for item in self._project_list.selectedItems(): + code = item.data(QtCore.Qt.UserRole) + if code: + selected_projects.append(code) + + # Properties + properties = {} + for key, (widget, pdef) in self._prop_widgets.items(): + val = _read_field(widget, pdef) + if val is not None and val != "": + properties[key] = val + + return { + "category": category, + "description": description, + "item_type": item_type, + "sourcing_type": sourcing_type, + "standard_cost": standard_cost, + "sourcing_link": sourcing_link, + "long_description": long_description, + "projects": selected_projects if selected_projects else None, + "properties": properties if properties else None, + } + + def _on_create(self): + """Validate and submit the form.""" + data = self._collect_form_data() + + if not data["category"]: + QtWidgets.QMessageBox.warning(self, "Validation", "Category is required.") + return + + try: + result = self._client.create_item( + "kindred-rd", + data["category"], + data["description"], + projects=data["projects"], + ) + self._result = result + self._result["_form_data"] = data + self.accept() + except Exception as e: + QtWidgets.QMessageBox.critical(self, "Error", f"Failed to create item:\n{e}") + + def exec_and_create(self): + """Show dialog and return the creation result, or None if cancelled.""" + if self.exec_() == QtWidgets.QDialog.Accepted: + return self._result + return None diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index ce4c462..3fe609a 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -748,84 +748,25 @@ class Silo_New: def Activated(self): from PySide import QtGui + from schema_form import SchemaFormDialog + sel = FreeCADGui.Selection.getSelection() - # Category selection - try: - schema = _client.get_schema() - categories = schema.get("segments", []) - cat_segment = next((s for s in categories if s.get("name") == "category"), None) - if cat_segment and cat_segment.get("values"): - cat_list = [f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())] - category_str, ok = QtGui.QInputDialog.getItem( - None, "New Item", "Category:", cat_list, 0, False - ) - if not ok: - return - category = category_str.split(" - ")[0] - else: - category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:") - if not ok: - return - except Exception: - category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:") - if not ok: - return + dlg = SchemaFormDialog(_client, parent=FreeCADGui.getMainWindow()) - # Description - default_desc = sel[0].Label if sel else "" - description, ok = QtGui.QInputDialog.getText( - None, "New Item", "Description:", text=default_desc - ) - if not ok: + # Pre-fill description from selected object + if sel: + dlg._desc_edit.setText(sel[0].Label) + + result = dlg.exec_and_create() + if result is None: return - # Optional project tagging - selected_projects = [] - try: - projects = _client.get_projects() - if projects: - project_codes = [p.get("code", "") for p in projects if p.get("code")] - if project_codes: - # Multi-select dialog for projects - dialog = QtGui.QDialog() - dialog.setWindowTitle("Tag with Projects (Optional)") - dialog.setMinimumWidth(300) - layout = QtGui.QVBoxLayout(dialog) - - label = QtGui.QLabel("Select projects to tag this item with:") - layout.addWidget(label) - - list_widget = QtGui.QListWidget() - list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection) - for code in project_codes: - list_widget.addItem(code) - layout.addWidget(list_widget) - - btn_layout = QtGui.QHBoxLayout() - skip_btn = QtGui.QPushButton("Skip") - ok_btn = QtGui.QPushButton("Tag Selected") - btn_layout.addWidget(skip_btn) - btn_layout.addWidget(ok_btn) - layout.addLayout(btn_layout) - - skip_btn.clicked.connect(dialog.reject) - ok_btn.clicked.connect(dialog.accept) - - if dialog.exec_() == QtGui.QDialog.Accepted: - selected_projects = [item.text() for item in list_widget.selectedItems()] - except Exception as e: - FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n") + part_number = result["part_number"] + form_data = result.get("_form_data", {}) + selected_projects = form_data.get("projects") or [] try: - result = _client.create_item( - "kindred-rd", - category, - description, - projects=selected_projects if selected_projects else None, - ) - part_number = result["part_number"] - if sel: # Tag selected object obj = sel[0]