diff --git a/ipywidgets_jsonschema/form.py b/ipywidgets_jsonschema/form.py index bfd3536..823c6d5 100644 --- a/ipywidgets_jsonschema/form.py +++ b/ipywidgets_jsonschema/form.py @@ -219,7 +219,9 @@ def data(self, _data): self._form_element.setter(_data) def _construct(self, schema, label=None, root=False): - if schema in self._construction_stack: + if schema in self._construction_stack and not ( + "$ref" in schema or "enum" in schema + ): return self.construct_element() self._construction_stack.append(schema) @@ -315,27 +317,65 @@ def _construct_object(self, schema, label=None, root=False): if "properties" in schema: for prop, subschema in schema["properties"].items(): elements[prop] = self._construct(subschema, label=prop) + elif "additionalProperties" in schema: + # Handle dictionaries with dynamically defined keys and values + elements["dict_container"] = self._construct_dict( + schema, label=label + ) # call the new function for dict + + widget_list = elements[ + "dict_container" + ].widgets # get dict_container widgets + # Maybe wrap this in an Accordion widget + wrapped_widget_list = widget_list + if not root and label is not None: + wrapped_widget_list = self._wrap_accordion( + widget_list, schema, label=label + ) - # Add conditional elements - def add_conditional_elements(s): - # Check whether we have an if statement - cond = s.get("if", None) - if cond is None: - return + def _getter(): + return elements["dict_container"].getter() - for cprop, csubschema in ( - s.get("then", {}).get("properties", {}).items() - ): - celem = self._construct(csubschema, label=cprop) - conditionals.append((cond, cprop, celem)) - elements[cprop] = celem + def _setter(_d): + elements["dict_container"].setter(_d) + + def _register_observer(h, n, t): + elements["dict_container"].register_observer(h, n, t) + + def _resetter(): + elements["dict_container"].resetter() + + return self.construct_element( + getter=_getter, + setter=_setter, + resetter=_resetter, + widgets=wrapped_widget_list, + subelements=elements, + register_observer=_register_observer, + ) + + else: + widget_list = [] + + # Handle the conditionals + def add_conditional_elements(s): + # Check whether we have an if statement + cond = s.get("if", None) + if cond is None: + return + + for cprop, csubschema in s.get("then", {}).get("properties", {}).items(): + celem = self._construct(csubschema, label=cprop) + conditionals.append((cond, cprop, celem)) + elements[cprop] = celem - if "else" in s: - add_conditional_elements(s["else"]) + if "else" in s: + add_conditional_elements(s["else"]) - add_conditional_elements(schema) + add_conditional_elements(schema) - # Apply sorting to the keys + # Apply sorting to the keys + if "properties" in schema: keys = schema["properties"].keys() try: keys = self.sorter(keys) @@ -356,7 +396,11 @@ def add_conditional_elements(s): # Maybe wrap this in an Accordion widget wrapped_widget_list = widget_list - if not root and len(schema.get("properties", {})) > 1: + if ( + not root + and len(schema.get("properties", {})) > 1 + and not "additionalProperties" in schema + ): wrapped_widget_list = self._wrap_accordion(widget_list, schema, label=label) def _getter(): @@ -390,11 +434,15 @@ def _register_observer(h, n, t): if "properties" in schema: for e in elements.values(): e.register_observer(h, n, t) + elif "additionalProperties" in schema: + elements["dict_container"].register_observer(h, n, t) def _resetter(): if "properties" in schema: for e in elements.values(): e.resetter() + elif "additionalProperties" in schema: + elements["dict_container"].resetter() # Add the conditional information if "properties" in schema: @@ -443,6 +491,121 @@ def _cond_observer(_): register_observer=_register_observer, ) + def _construct_dict(self, schema, label=None): + if "additionalProperties" not in schema: + raise FormError( + f"Expecting 'additionalProperties' key in schema for type dict: {schema}" + ) + + additional_props_schema = schema["additionalProperties"] + + # container for the input widgets + + widget = ipywidgets.VBox([]) + + elements = [] # list of widgets corresponding to each key + + def _update_widget(): + widget.children = [ipywidgets.VBox(e.widgets) for e in elements] + + def add_dict_entry(key=None, value=None): + if key is None or key == "": + key = "key" + str(len(elements)) + + key_widget = ipywidgets.Text(value=key, description="key") + + elem_dict = self._construct(additional_props_schema) + + trash = ipywidgets.Button(icon="trash") + + def remove_entry(_): + # Identify the current list index of the entry + for index, child in enumerate(widget.children): + if trash in child.children: + break + + elements.pop(index) + + # Remove it from the widget list and the handler list + _update_widget() + + trash.on_click(remove_entry) + + def _dict_getter(): + return {key_widget.value: elem_dict.getter()} + + def _dict_setter(_dict): + + if key_widget.value in _dict: + elem_dict.setter(_dict[key_widget.value]) + else: + elem_dict.resetter() + + elements.append( + self.construct_element( + getter=_dict_getter, + setter=_dict_setter, + resetter=elem_dict.resetter, + widgets=[ + ipywidgets.HBox( + [key_widget, ipywidgets.VBox(elem_dict.widgets), trash] + ) + ], + ) + ) + _update_widget() + + add_btn = ipywidgets.Button( + description="Add key value", + icon="plus", + layout=ipywidgets.Layout(width="100%"), + ) + add_btn.on_click(lambda x: add_dict_entry()) + + widget_list = [widget, add_btn] + + def _getter(): + + data = {} + for e in elements: + data.update(e.getter()) + + return data + + def _setter(_d): + + for e in elements: + key = list(e.getter().keys())[0] + if key in _d: + e.setter(_d) + else: + e.resetter() + + # check for keys that need to added + keys = [list(e.getter().keys())[0] for e in elements] + for key, value in _d.items(): + + if key not in keys: + add_dict_entry(key=key, value=value) + + def _register_observer(h, n, t): + for e in elements: + e.register_observer(h, n, t) + + def _resetter(): + if "default" in schema: + _setter(schema["default"]) + + _resetter() + + return self.construct_element( + getter=_getter, + setter=_setter, + resetter=_resetter, + widgets=widget_list, + register_observer=_register_observer, + ) + def _construct_simple(self, schema, widget, label=None, root=False): # Extract the best description that we have tooltip = schema.get("description", None) @@ -858,7 +1021,10 @@ def _construct_array(self, schema, label=None, root=False): # Trigger whenever the resulting widget needs update def update_widget(): - subwidgets = sum((e.widgets for e in elements[:element_size]), []) + subwidgets = [] + for e in elements[:element_size]: + if e.widgets: + subwidgets.extend(e.widgets) if not fixed_length: subwidgets.append(button) vbox.children = subwidgets @@ -917,8 +1083,8 @@ def remove_entry(b): return # Identify the current list index of the entry - for index, child in enumerate(vbox.children[:-1]): - if b in child.children[1].children: + for index, child in enumerate(vbox.children): + if child.children and b in child.children[1].children: break # Move the corresponding element to the back of the list @@ -936,9 +1102,9 @@ def remove_entry(b): def move(dir): def _move(b): - items = list(vbox.children[:-1]) + items = list(vbox.children) for i, it in enumerate(items): - if b in it.children[1].children: + if it.children and b in it.children[1].children: newi = min(max(i + dir, 0), len(items) - 1) items[i], items[newi] = items[newi], items[i] elements[i], elements[newi] = ( @@ -955,11 +1121,10 @@ def _move(b): up.on_click(move(-1)) down.on_click(move(1)) + array_entry_widget = ipywidgets.VBox() children = [] - if recelem.widgets and recelem.widgets[0].children: - children = [ - recelem.widgets[0].children[0], - ] + if recelem.widgets: + children.append(ipywidgets.VBox(recelem.widgets)) if not fixed_length: children.append( ipywidgets.HBox( @@ -967,7 +1132,7 @@ def _move(b): ) ) - array_entry_widget = ipywidgets.VBox(children) + array_entry_widget.children = children # Modify recelem to our needs elemdict = recelem._asdict() diff --git a/tests/models/Dictionary.py b/tests/models/Dictionary.py new file mode 100644 index 0000000..8c9ae73 --- /dev/null +++ b/tests/models/Dictionary.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + + +class DictionaryModel(BaseModel): + name: str + settings: dict[str, int] + + @classmethod + def valid_cases(cls): + """Provide valid cases for the model.""" + return [{"name": "Test", "settings": {"key1": 1}}] + + @classmethod + def invalid_cases(cls): + """Provide invalid cases for the model.""" + return [{"name": "Test", "settings": {"key1": "string"}}, {"name": "Test"}] + + @classmethod + def default_values(cls): + """Provide default values for the model.""" + return {"name": "", "settings": {}}