From 32f2db680281cabde3159b9a17702cfa23b2c200 Mon Sep 17 00:00:00 2001
From: jensilo <k@jensheise.com>
Date: Fri, 15 Dec 2023 12:35:46 +0100
Subject: [PATCH] improve ui for optional equal rule, add more tests for
 parser, adjust templates to new change to maintain consistency

---
 docs/templates/paris/ak.json               |  12 +-
 docs/templates/paris/esfa.json             |   1 +
 docs/templates/paris/esqua.json            |   1 +
 docs/templates/paris/glossar.json          |   5 +
 src/app/eiffel/parser.go                   |  69 ++++++-----
 src/app/eiffel/parser_test.go              | 130 ++++++++++++++++-----
 templates/eiffel/_form-elicitation.go.html |  26 ++++-
 7 files changed, 184 insertions(+), 60 deletions(-)

diff --git a/docs/templates/paris/ak.json b/docs/templates/paris/ak.json
index a5b3642..3800df2 100644
--- a/docs/templates/paris/ak.json
+++ b/docs/templates/paris/ak.json
@@ -21,6 +21,7 @@
         "Gegeben,"
       ],
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "small"
     },
     "prae-bedingung-kurz": {
@@ -35,7 +36,8 @@
       "hint": "Welche Situation liegt vor?",
       "explanation": "Beschreibung der Situation vor dem Ereignis. Beispiel: Angenommen, der Anwender X hat ein Meeting für den Anwender X für den 1.3.201x von 13:00 bis 15:00 angelegt.",
       "size": "large",
-      "optional": true
+      "optional": true,
+      "ignoreMissingWhenOptional": true
     },
     "prae-ereignis": {
       "name": "Prä-Ereignis",
@@ -46,6 +48,7 @@
         "Prüfe"
       ],
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "small"
     },
     "prae-ereignis-kurz": {
@@ -60,7 +63,8 @@
       "hint": "Was soll passieren?",
       "explanation": "Beschreibung Ereignis(se) bzw. Aktion(en). Beispiel: Wenn ein anderer Anwender Y für den Anwender X ein zweites Meeting für den 1.3.201x von 13:00 bis 15:00 anlegen will,",
       "size": "large",
-      "optional": true
+      "optional": true,
+      "ignoreMissingWhenOptional": true
     },
     "prae-ergebnis": {
       "name": "Prä-Ergebnis",
@@ -71,6 +75,7 @@
         "dann"
       ],
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "small"
     },
     "prae-ergebnis-kurz": {
@@ -92,7 +97,8 @@
       "hint": "Warum ist es so?",
       "explanation": "Beschreibung der Begründung für das Ergebnis. Beispiel: weil ein Anwender immer nur in einem Meeting gleichzeitig anwesend sein kann.",
       "size": "large",
-      "optional": true
+      "optional": true,
+      "ignoreMissingWhenOptional": true
     },
     "punkt": {
       "name": "Punkt",
diff --git a/docs/templates/paris/esfa.json b/docs/templates/paris/esfa.json
index 167f911..f303c76 100644
--- a/docs/templates/paris/esfa.json
+++ b/docs/templates/paris/esfa.json
@@ -98,6 +98,7 @@
       "hint": "Warum wird es getan?",
       "explanation": "Beispiel: weil dieser manuell nach Auffälligkeiten suchen will",
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "large"
     },
     "punkt": {
diff --git a/docs/templates/paris/esqua.json b/docs/templates/paris/esqua.json
index 6e3cad9..f7c1e96 100644
--- a/docs/templates/paris/esqua.json
+++ b/docs/templates/paris/esqua.json
@@ -84,6 +84,7 @@
       "hint": "Warum?",
       "explanation": "Beispiel: weil die Mitarbeiter in Ihren Pausen keine Wartezeiten tolerieren",
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "large"
     },
     "punkt": {
diff --git a/docs/templates/paris/glossar.json b/docs/templates/paris/glossar.json
index 689dc16..3a56fe2 100644
--- a/docs/templates/paris/glossar.json
+++ b/docs/templates/paris/glossar.json
@@ -30,6 +30,7 @@
       "hint": "Mit welchen Synonymen?",
       "explanation": "Synonyme für den zu erklärenden Begriff. Beispiel: (Synonyme: Ausleiher, Besucher, Leser)",
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "medium"
     },
     "uebersetzung": {
@@ -38,6 +39,7 @@
       "hint": "Mit welchen Übersetzungen?",
       "explanation": "Übersetzung des zu erklärenden Begriffs. Beispiel: (engl. Reader)",
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "medium"
     },
     "bedeutet": {
@@ -55,6 +57,7 @@
         "in der"
       ],
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "small"
     },
     "bereich": {
@@ -79,6 +82,7 @@
         "den Prozess"
       ],
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "small"
     },
     "definiens": {
@@ -102,6 +106,7 @@
       "hint": "Warum ist es so festgelegt?",
       "explanation": "Begründung für die Definition. Beispiel: weil juristische Personen nicht zur Ausleihe zugelassen sind",
       "optional": true,
+      "ignoreMissingWhenOptional": true,
       "size": "large"
     },
     "punkt": {
diff --git a/src/app/eiffel/parser.go b/src/app/eiffel/parser.go
index dfbff29..d805640 100644
--- a/src/app/eiffel/parser.go
+++ b/src/app/eiffel/parser.go
@@ -76,6 +76,10 @@ type BasicRule struct {
 	// Optional means a rule is not required to be parsed without template.ParsingLogLevelError (parsing error).
 	// By that parsing an invalid requirement for an optional rule will not result in a parsing error.
 	Optional bool `json:"optional"`
+	// IgnoreMissingWhenOptional means that a missing or empty segment for this rule will not be displayed at all.
+	// Usually, a missing or empty segment for an optional rule will be an error downgraded to a notice.
+	// However, if this flag is set to true, the segment will not be displayed at all.
+	IgnoreMissingWhenOptional bool `json:"ignoreMissingWhenOptional"`
 	// Size is the expected size of the rule's value. It is optional. Possible values are:
 	//  - "small" (default): The value is expected to be a short string. 1/4 of the input field width.
 	//  - "medium": The value is expected to be a medium-sized string. 2/4 of the input field width.
@@ -220,11 +224,14 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi
 			return result, RuleMissingError{Rule: ruleName, Template: bt.Name, Variant: variant.Name}
 		}
 
+		var parsingLogs []parser.ParsingLog
 		segment, ok := indexedSegments[ruleName]
-		if (!ok || segment.Value == "") && !rule.Optional {
-			result.Errors = append(result.Errors, parser.ParsingLog{
+
+		isMissing := !ok || segment.Value == ""
+		if isMissing {
+			parsingLogs = append(parsingLogs, parser.ParsingLog{
 				Segment: &parser.ParsingSegment{Name: ruleName},
-				Level:   parser.ParsingLogLevelError,
+				Level:   parser.ParsingLogLevelError, // if optional this will be downgraded to a notice in a moment
 				Message: "eiffel.parser.error.missing-segment",
 				TranslationArgs: []string{
 					"name",
@@ -233,36 +240,22 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi
 					ruleName,
 				},
 			})
-			continue
-		}
-
-		if !ok || segment.Value == "" {
-			continue // rule is optional and segment is missing -> ignore
 		}
 
-		ruleParser, err := ruleParsers.Parser(rule.Type)
-		if err != nil {
-			return result, err // should also never happen because the template is expected to be valid
+		if rule.Optional && isMissing && rule.IgnoreMissingWhenOptional {
+			continue
 		}
 
-		parsingLogs, err := ruleParser.Parse(ctx, rule, segment)
-		if err != nil {
-			return result, err
-		}
+		if !isMissing {
+			var err error
+			parsingLogs, err = parse(ctx, ruleParsers, rule, segment)
+			if err != nil {
+				return result, err
+			}
 
-		be := " "
-		af := ""
-		before, bOk := rule.Extra["before"]
-		after, aOk := rule.Extra["after"]
-		if bStr, ok := before.(string); ok && bOk {
-			be = bStr
-		}
-		if aStr, ok := after.(string); ok && aOk {
-			af = aStr
+			buildRequirementIncrementally(rule, segment, &result)
 		}
 
-		result.Requirement += be + strings.TrimSpace(segment.Value) + af
-
 		for _, log := range parsingLogs {
 			switch log.Level {
 			case parser.ParsingLogLevelError:
@@ -286,6 +279,30 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi
 	return result, nil
 }
 
+func parse(ctx context.Context, ruleParsers *RuleParserProvider, rule BasicRule, segment parser.ParsingSegment) ([]parser.ParsingLog, error) {
+	ruleParser, err := ruleParsers.Parser(rule.Type)
+	if err != nil {
+		return nil, err
+	}
+
+	return ruleParser.Parse(ctx, rule, segment)
+}
+
+func buildRequirementIncrementally(rule BasicRule, segment parser.ParsingSegment, result *parser.ParsingResult) {
+	be := " "
+	af := ""
+	before, bOk := rule.Extra["before"]
+	after, aOk := rule.Extra["after"]
+	if bStr, ok := before.(string); ok && bOk {
+		be = bStr
+	}
+	if aStr, ok := after.(string); ok && aOk {
+		af = aStr
+	}
+
+	result.Requirement += be + strings.TrimSpace(segment.Value) + af
+}
+
 // Validate makes sure that the template is valid. It checks the structure of and semantic of the values inside.
 // Each rule configuration is validated by the corresponding rule parser.
 // It returns a slice of validation errors that are safe to show to the user (translatable).
diff --git a/src/app/eiffel/parser_test.go b/src/app/eiffel/parser_test.go
index d11cd62..39a3157 100644
--- a/src/app/eiffel/parser_test.go
+++ b/src/app/eiffel/parser_test.go
@@ -28,30 +28,107 @@ func TestBasicParser_Parse(t *testing.T) {
 	bt := basicTemplate()
 	rp := ruleParsers()
 
-	errs := bt.Validate(validation.New(), rp)
-	require.Len(t, errs, 0)
+	t.Run("basic variant is valid", func(t *testing.T) {
+		errs := bt.Validate(validation.New(), rp)
+		require.Len(t, errs, 0)
+	})
+
+	t.Run("basic variant parsing with error in optional rule downgraded to notice", func(t *testing.T) {
+		parsingResult, err := bt.Parse(
+			context.Background(),
+			rp,
+			"basicVariant",
+			parser.ParsingSegment{Name: "pre", Value: "This "},
+			parser.ParsingSegment{Name: "stateVerbRule", Value: "is"},
+			parser.ParsingSegment{Name: "mid", Value: "a"},
+			parser.ParsingSegment{Name: "fooRule", Value: " foo "},
+			parser.ParsingSegment{Name: "fooPostfixRule", Value: "example"},
+			parser.ParsingSegment{Name: "end", Value: "."},
+			parser.ParsingSegment{Name: "optionalErrorTestRule", Value: "bar"},
+		)
+
+		require.NoError(t, err)
+		require.Len(t, parsingResult.Notices, 1)
+		assert.True(t, parsingResult.Notices[0].Downgrade)
+		assert.Equal(t, "test-template", parsingResult.TemplateID)
+		assert.True(t, parsingResult.Ok(), "parsing result should be ok but parsing errors occurred")
+		assert.True(t, parsingResult.Flawless(), "parsing result should be flawless")
+		assert.Equal(t, parsingResult.Notices[0].Segment.Name, "optionalErrorTestRule")
+		assert.True(t, parsingResult.Notices[0].Downgrade, "notice should be downgraded for optional rule")
+	})
+
+	t.Run("missing optional rule with error downgraded to notice", func(t *testing.T) {
+		parsingResult, err := bt.Parse(
+			context.Background(),
+			rp,
+			"basicVariant",
+			parser.ParsingSegment{Name: "pre", Value: "This "},
+			parser.ParsingSegment{Name: "stateVerbRule", Value: "is"},
+			parser.ParsingSegment{Name: "mid", Value: "a"},
+			parser.ParsingSegment{Name: "fooRule", Value: " foo "},
+			parser.ParsingSegment{Name: "fooPostfixRule", Value: "example"},
+			parser.ParsingSegment{Name: "end", Value: "."},
+			parser.ParsingSegment{Name: "optionalErrorTestRule", Value: "bar"},
+		)
+
+		require.NoError(t, err)
+		require.Len(t, parsingResult.Notices, 1)
+		assert.True(t, parsingResult.Notices[0].Downgrade)
+	})
+
+	t.Run("missing input for a required (non-optional) rule error", func(t *testing.T) {
+		bt := basicTemplate()
+
+		parsingResult, err := bt.Parse(
+			context.Background(),
+			rp,
+			"basicVariant",
+			parser.ParsingSegment{Name: "pre", Value: "This "},
+			parser.ParsingSegment{Name: "stateVerbRule", Value: "wrong-state-verb"},
+			parser.ParsingSegment{Name: "mid", Value: "a"},
+			parser.ParsingSegment{Name: "fooRule", Value: "bar-missing-foo"},
+			parser.ParsingSegment{Name: "fooPostfixRule", Value: "example"},
+			parser.ParsingSegment{Name: "end", Value: "."},
+		)
 
-	parsingResult, err := bt.Parse(
-		context.Background(),
-		rp,
-		"basicVariant",
-		parser.ParsingSegment{Name: "pre", Value: "This "},
-		parser.ParsingSegment{Name: "stateVerbRule", Value: "is"},
-		parser.ParsingSegment{Name: "mid", Value: "a"},
-		parser.ParsingSegment{Name: "fooRule", Value: " foo "},
-		parser.ParsingSegment{Name: "fooPostfixRule", Value: "example"},
-		parser.ParsingSegment{Name: "end", Value: "."},
-		parser.ParsingSegment{Name: "optionalErrorTestRule", Value: "bar"},
-	)
-
-	require.NoError(t, err)
-	require.Len(t, parsingResult.Notices, 1)
-	assert.True(t, parsingResult.Notices[0].Downgrade)
-	assert.Equal(t, "test-template", parsingResult.TemplateID)
-	assert.True(t, parsingResult.Ok(), "parsing result should be ok but parsing errors occurred")
-	assert.True(t, parsingResult.Flawless(), "parsing result should be flawless")
-	assert.Equal(t, parsingResult.Notices[0].Segment.Name, "optionalErrorTestRule")
-	assert.True(t, parsingResult.Notices[0].Downgrade, "notice should be downgraded for optional rule")
+		require.NoError(t, err)
+		require.Len(t, parsingResult.Errors, 2)
+	})
+
+	t.Run("invalid variant, expecting error from parse", func(t *testing.T) {
+		_, err := bt.Parse(context.Background(), rp, "invalidVariant")
+		assert.Error(t, err)
+	})
+
+	t.Run("invalid template config, expecting error from parse", func(t *testing.T) {
+		bt.Rules = map[string]BasicRule{}
+		_, err := bt.Parse(context.Background(), rp, "basicVariant")
+		assert.Error(t, err)
+	})
+
+	t.Run("valid variant, valid configuration, valid requirement, no errors, flawless result w/o notices", func(t *testing.T) {
+		bt := basicTemplate()
+		rp := ruleParsers()
+
+		parsingResult, err := bt.Parse(
+			context.Background(),
+			rp,
+			"basicVariant",
+			parser.ParsingSegment{Name: "pre", Value: "This "},
+			parser.ParsingSegment{Name: "stateVerbRule", Value: "is"},
+			parser.ParsingSegment{Name: "mid", Value: "a"},
+			parser.ParsingSegment{Name: "fooRule", Value: " foo "},
+			parser.ParsingSegment{Name: "fooPostfixRule", Value: "example"},
+			parser.ParsingSegment{Name: "end", Value: "."},
+			parser.ParsingSegment{Name: "optionalErrorTestRule", Value: "foo"},
+		)
+
+		require.NoError(t, err)
+		require.Len(t, parsingResult.Errors, 0)
+		require.Len(t, parsingResult.Notices, 0)
+		assert.True(t, parsingResult.Ok(), "parsing result should be ok but parsing errors occurred")
+		assert.True(t, parsingResult.Flawless(), "parsing result should be flawless")
+	})
 }
 
 func basicTemplate() *BasicTemplate {
@@ -87,9 +164,10 @@ func basicTemplate() *BasicTemplate {
 				Optional: true,
 			},
 			"optionalMissingTestRule": {
-				Name:     "Optional Missing Test Rule",
-				Type:     "placeholder",
-				Optional: true,
+				Name:                      "Optional Missing Test Rule",
+				Type:                      "placeholder",
+				Optional:                  true,
+				IgnoreMissingWhenOptional: true,
 			},
 			"optionalErrorTestRule": {
 				Name:     "Optional Empty Error Test Rule",
diff --git a/templates/eiffel/_form-elicitation.go.html b/templates/eiffel/_form-elicitation.go.html
index 350dae4..f52c6e9 100644
--- a/templates/eiffel/_form-elicitation.go.html
+++ b/templates/eiffel/_form-elicitation.go.html
@@ -46,6 +46,10 @@
                         {{ if or (eq $displayType "input-text")
                         (eq $displayType "text")
                         (eq $displayType "input-single-select") }}
+
+                            {{ $optionalText := and (eq $displayType "text") $rule.Optional }}
+                            {{ $nonOptionalText := and (eq $displayType "text") (not $optionalText) }}
+
                             <div class="mb-3">
                                 <div class="input-group {{ if $violations }}has-validation{{ end }}">
                                     <span data-bs-target="#eiffelRule-{{ $ruleName }}-info" class="input-group-text" role="button" data-bs-toggle="modal">i</span>
@@ -59,7 +63,7 @@
                                         </datalist>
                                     {{ end }}
 
-                                    {{ if eq $displayType "text" }}
+                                    {{ if $nonOptionalText }}
                                         <input type="hidden" name="{{ $inputName }}" value="{{ $rule.Value }}" />
                                     {{ end }}
 
@@ -67,12 +71,24 @@
                                         id="eiffelFormInput-{{ $ruleName }}"
                                         class="form-control {{ if $violations }}is-invalid{{ end }}"
                                         name="{{ $inputName }}"
-                                        placeholder="{{ $displayName}}"
                                         aria-label="{{ $displayName }}"
                                         aria-description="{{ $rule.Hint }}"
-                                        {{ if eq $displayType "text" }}
-                                            disabled value="{{ $rule.Value }}"
-                                        {{ else if $parsingResult }}value="{{ index $segments $ruleName }}"{{ end }}
+
+                                        {{ if $nonOptionalText }} {{/* show fixed text in input */}}
+                                            value="{{ $rule.Value }}"
+                                            disabled
+                                        {{ else if $parsingResult }} {{/* show content from last submit for optional text show value as placeholder */}}
+                                            value="{{ index $segments $ruleName }}"
+                                        {{ else if $optionalText }} {{/* show value that's suggested by the rule */}}
+                                            value="{{ $rule.Value }}"
+                                        {{ end }}
+
+                                        {{ if $optionalText }}
+                                            placeholder="{{ $rule.Value }}"
+                                        {{ else }}
+                                            placeholder="{{ $displayName }}"
+                                        {{ end }}
+
                                         {{ if eq $displayType "input-single-select" }}list="eiffelFormInput-{{ $ruleName }}-datalist"{{ end }}
                                         {{ if not $rule.Optional }}required{{ end }}
                                         {{ if $first }}autofocus{{ end }}
-- 
GitLab