Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions strictdoc/backend/sdoc/models/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,25 @@ def get_prefix_for_new_node(self, node_type: str) -> Optional[str]:

return self.get_prefix()

def is_managed_by_source_code(self) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thseiler I started rebasing your branch and it needed a few changes that led me to editing your commit directly instead of push an additional code review one. The summary of my changes:

  • I removed ActionPolicy class. Instead I moved most methods to the DocumentScreenViewObject and here as is_managed_by_source_code. One pattern here I tried to avoid is to add global logic to Jinja templates as that contradicts to the currently adopted pattern of routing everything through the view object objects.
  • I did a minor clean up of the new e2e test fixtures, removing some of the unneeded files.

I am still reviewing the impact and consistency of your other changes, expecially the file_traceability_index. and update_requirement.py. Should be done by the end of this weekend.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, @mettta will review the CSS styles to ensure that this change is consistent with everything else.

"""
Helper method to check if a node is partially managed by source code.
"""

# Is the node entirely generated from source code?
if self.autogen:
return True

# Check if fields were merged from source-files.
for field_list in self.ordered_fields_lookup.values():
for field in field_list:
# If any field did not originate from the document,
# the node's content is partially managed by source code...
if not field.is_document_origin():
return True

return False

def dump_fields_as_parsed(self) -> str:
# FIXME:
# - The name of the method can be improved (used in error messages).
Expand Down
9 changes: 8 additions & 1 deletion strictdoc/backend/sdoc/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,14 @@ def _print_requirement_fields(
fields = section_content.ordered_fields_lookup[field_name]

for field in fields:
if not field.is_document_origin():
# When writing the document back, we skip (normalize) any non-required
# fields that were merged-in from source code (single source of truth).
# But we need to keep UID or MID around for merging.
if (
not field.is_document_origin()
and not element_field.required
and field_name not in ("UID", "MID")
):
continue

field_value = field.get_text_value()
Expand Down
13 changes: 6 additions & 7 deletions strictdoc/core/file_traceability_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,19 +1085,18 @@ def set_sdoc_node_fields(
sdoc_node: SDocNode, sdoc_node_fields: dict[str, str]
) -> None:
for field_name, field_value in sdoc_node_fields.items():
sdoc_node_has_field = field_name in sdoc_node.ordered_fields_lookup

sdoc_node.set_field_value(
field_name=field_name,
form_field_index=0,
value=field_value,
)

if not sdoc_node_has_field:
new_field: SDocNodeField = sdoc_node.ordered_fields_lookup[
field_name
][0]
new_field.mark_as_source_origin()
# As we overwrite the field's content from the source code,
# we mark the field as source_origin here.
new_field: SDocNodeField = sdoc_node.ordered_fields_lookup[
field_name
][0]
new_field.mark_as_source_origin()

@staticmethod
def create_source_node_section(
Expand Down
92 changes: 61 additions & 31 deletions strictdoc/core/transforms/update_requirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ def perform(self) -> Optional[CreateOrUpdateNodeResult]:
requirement.ordered_fields_lookup.clear()

self.populate_node_fields_from_form_object(
requirement, form_object, map_form_to_requirement_fields
requirement,
form_object,
map_form_to_requirement_fields,
existing_node_fields,
)

# Updating Traceability Index: UID.
Expand Down Expand Up @@ -298,7 +301,7 @@ def perform(self) -> Optional[CreateOrUpdateNodeResult]:
parent.section_contents.insert(insert_to_idx, requirement)

self.populate_node_fields_from_form_object(
requirement, form_object, map_form_to_requirement_fields
requirement, form_object, map_form_to_requirement_fields, []
)
traceability_index.create_requirement(requirement=requirement)

Expand Down Expand Up @@ -480,7 +483,17 @@ def populate_node_fields_from_form_object(
map_form_to_requirement_fields: Dict[
RequirementFormField, Optional[FreeTextContainer]
],
existing_node_fields: Optional[List[SDocNodeField]] = None,
) -> None:
if existing_node_fields is None:
existing_node_fields = []

existing_fields_lookup: Dict[str, List[SDocNodeField]] = {}
for f in existing_node_fields:
if f.field_name not in existing_fields_lookup:
existing_fields_lookup[f.field_name] = []
existing_fields_lookup[f.field_name].append(f)

# FIXME: Leave only one method based on set_field_value().
for form_field_name, form_fields in form_object.fields.items():
for form_field_index, form_field in enumerate(form_fields):
Expand All @@ -490,34 +503,51 @@ def populate_node_fields_from_form_object(
form_field_index=form_field_index,
value=form_field.field_value,
)
continue

free_text_content: Optional[FreeTextContainer] = (
map_form_to_requirement_fields[form_field]
)
requirement_field: Optional[SDocNodeField] = (
SDocNodeField(
node,
else:
free_text_content: Optional[FreeTextContainer] = (
map_form_to_requirement_fields[form_field]
)
requirement_field: Optional[SDocNodeField] = (
SDocNodeField(
node,
field_name=form_field_name,
parts=free_text_content.parts,
multiline__="true"
if form_field.field_type
== RequirementFormFieldType.MULTILINE
else None,
)
if free_text_content is not None
else None
)
node.set_field_value(
field_name=form_field_name,
parts=free_text_content.parts,
multiline__="true"
if form_field.field_type
== RequirementFormFieldType.MULTILINE
else None,
form_field_index=form_field_index,
value=requirement_field,
)
if free_text_content is not None
else None
)
node.set_field_value(
field_name=form_field_name,
form_field_index=form_field_index,
value=requirement_field,
)
if (
requirement_field is not None
and free_text_content is not None
):
for part_ in requirement_field.parts:
if isinstance(part_, str):
continue
part_.parent = requirement_field
if (
requirement_field is not None
and free_text_content is not None
):
for part_ in requirement_field.parts:
if isinstance(part_, str):
continue
part_.parent = requirement_field

# Preserve the existing origin metadata as is was not passed round-trip via the form.
new_field_list = node.ordered_fields_lookup.get(form_field_name)
if new_field_list and form_field_index < len(new_field_list):
new_field = new_field_list[form_field_index]

# Find the corresponding old field (if it existed)
if (
form_field_name in existing_fields_lookup
and form_field_index
< len(existing_fields_lookup[form_field_name])
):
old_field = existing_fields_lookup[form_field_name][
form_field_index
]

if hasattr(old_field, "origin"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR had a few instances of this. @thseiler I am curious if this is produced by an AI tool. Which tool did you possibly use to create it? I would be curious if we could extend our Dev Guide and let all AI tools avoid creating this pattern in the future.

(I have been using Codex until now and haven't seen this pattern from it)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code was proposed by Gemini 3.1 Pro when it was reviewing my changes. It insisted that SDocDocument/SDocNodes were not just DOM objects but Abstract Syntax Tree objects (from the textX parser). It proposed duck typing to keep code decoupled from grammar, allowing for easier future evolution of the grammar. I have to admit I don't know enough about textX and DSL design and their best practices, but the argument seemed plausible...

Thanks for the tip, I will give Codex a test drive...

new_field.origin = old_field.origin
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ ${DL_ITEM_SELECTOR}[draggable="true"] .dragIndicator::before {
top: 0; left: 100%;
content: "⋮⋮";
}
${DL_ITEM_SELECTOR}[data-can-move="false"]:active,
${DL_ITEM_SELECTOR}[data-can-move="false"]:active * {
cursor: not-allowed;
}
[data-last_moved="true"] {
position: relative;
}
Expand Down Expand Up @@ -121,21 +125,27 @@ ${DL_ITEM_SELECTOR}[draggable="true"] .dragIndicator::before {
// Add event listeners.
const draggableList = [...this.element.querySelectorAll(DL_ITEM_SELECTOR)];
draggableList.forEach((item) => {
item.addEventListener('dragstart', dragStart);
item.addEventListener('dragend', dragEnd);
item.addEventListener('dragover', dragOver);
item.addEventListener('dragenter', dragEnter);
item.addEventListener('dragleave', dragLeave);
item.addEventListener('drop', dragDrop);

item.addEventListener("mouseover", mouseOver);
item.addEventListener("mouseleave", mouseLeave);

last_moved_node_id
&& (item.dataset.nodeid === last_moved_node_id)
&& (item.dataset.last_moved = 'true');

item.setAttribute('draggable', true);
if (item.dataset.canMove !== 'false') {
item.setAttribute('draggable', true);

item.addEventListener('dragstart', dragStart);
item.addEventListener('dragend', dragEnd);
item.addEventListener("mouseover", mouseOver);
item.addEventListener("mouseleave", mouseLeave);
} else {
// This item cannot move: Explicitly disable dragging
item.setAttribute('draggable', false);
}

item.addEventListener('dragover', dragOver);
item.addEventListener('dragenter', dragEnter);
item.addEventListener('dragleave', dragLeave);
item.addEventListener('drop', dragDrop);
});

// Prevent drag for links inside the list.
Expand Down
14 changes: 14 additions & 0 deletions strictdoc/export/html/_static/element.css
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,20 @@ sdoc-node-controls .action_button {
vertical-align: top;
}

.action_button--disabled {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(100%);
pointer-events: none;
}

/* We re-enable pointer-events on hover,
* so the tooltip (title attribute) shows up.
*/
.action_button--disabled:hover {
pointer-events: auto;
}

/* field_action */
/* TODO: optimize code with .action_icon & .action_button */

Expand Down
6 changes: 4 additions & 2 deletions strictdoc/export/html/_static/form.css
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ sdoc-autocompletable[data-field-suffix]:not(:empty)::after {

/* data-field-suffix */

[contenteditable=true] {
sdoc-contenteditable,
sdoc-autocompletable {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
Expand All @@ -459,7 +460,8 @@ sdoc-autocompletable[data-field-suffix]:not(:empty)::after {
width: 100%;
}

[contenteditable=true][data-field-type="multiline"] {
sdoc-contenteditable[data-field-type="multiline"],
sdoc-autocompletable[data-field-type="multiline"] {
font-family: var(--code-font-family);
}

Expand Down
3 changes: 3 additions & 0 deletions strictdoc/export/html/form_objects/requirement_form_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ def __init__(
field_type: RequirementFormFieldType,
field_value: str,
field_gef_type: str = RequirementFieldType.STRING,
is_editable: bool = True,
) -> None:
assert isinstance(field_value, str)
self.field_mid: str = field_mid
self.field_name: str = field_name
self.field_value: str = field_value
self.field_type = field_type
self.field_gef_type: str = field_gef_type
self.is_editable = is_editable

def is_multiline(self) -> bool:
return self.field_type == RequirementFormFieldType.MULTILINE
Expand Down Expand Up @@ -146,6 +148,7 @@ def create_existing_from_grammar_field(
),
field_value=field_value,
field_gef_type=grammar_field.gef_type,
is_editable=requirement_field.is_document_origin(),
)
raise NotImplementedError(grammar_field)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,84 @@ def get_html2pdf_classes(self, node: SDocNode) -> str:
html2pdf4doc_classes.append("html2pdf4doc-no-hanging")

return " ".join(html2pdf4doc_classes)

#
# Document Level Actions
#

def can_edit_document(self, document: SDocDocument) -> bool:
"""
Determines if the document's root configuration (title, metadata) can be edited.
"""
# If the entire document is auto-generated, we can't edit it.
if document.autogen:
return False

# If the document is a physical .sdoc file, its metadata is editable,
# regardless of whether it contains source-synced nodes inside it.
return True

#
# Node Level Actions
#

def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
if isinstance(node, SDocDocument):
return not node.autogen

# Ephemeral test reports/auto-docs cannot be edited at all.
if node.get_parent_or_including_document().autogen or node.autogen:
return False

# NOTE: We intentionally DO NOT check for hybrid fields here!
# If it's a hybrid node, we let them open the form. The field-level
# UI wrappers will mark the specific input fields that belong to the C-file as read-only.
return True

def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
if isinstance(node, SDocDocument):
return not node.autogen

# Ephemeral/Virtual documents cannot be edited
if node.get_parent_or_including_document().autogen:
return False

# Pure source-code nodes and Hybrid nodes cannot be deleted from the UI.
if node.is_managed_by_source_code():
return False

return True

def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
if isinstance(node, SDocDocument):
return False

# We cannot clone a source-managed node because the UI cannot write to the C-file.
# In this sense, the policy is identical to can_delete
return self.can_delete_node(node)

def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
if isinstance(node, SDocDocument):
return not node.autogen

# Cannot attach manual nodes to volatile, purely autogenerated nodes/docs.
# If the parent vanishes on the next sync, the children are lost.
if node.get_parent_or_including_document().autogen or node.autogen:
return False

# NOTE: We do not check for source managed fields here.
# A hybrid node has a permanent anchor in the .sdoc file, so it is safe
# to attach new manual siblings or children to it.
return True

def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
if isinstance(node, SDocDocument):
return not node.autogen

# Can't move nodes in ephemeral / autogenerated documents.
if node.get_parent_or_including_document().autogen:
return False

# But we can move a source-managed node, because the updates is limited
# to the sdoc document, moving does not require updating the source code.
return True
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
{%- assert field_input_name is defined, "field_input_name is defined" -%}
{%- assert field_label is defined, "field_label is defined" -%}
{%- assert field_label is not none, "field_label is not none" -%}
{# FIXME: Remove #}
{%- assert field_name is not defined, "field_name is not defined" -%}
{%- assert field_placeholder is defined, "field_placeholder is defined" -%}
{%- assert field_placeholder is not none, "field_placeholder is not none" -%}
{%- assert field_value is defined, "field_value is defined" -%}
Expand Down
Loading
Loading