From de2c80bb70558c5f71371cb0ed3da21f1775a654 Mon Sep 17 00:00:00 2001
From: jensilo <k@jensheise.com>
Date: Wed, 13 Dec 2023 00:51:50 +0100
Subject: [PATCH] remove output file and add output stream with js
functionality
---
public/assets/css/styles.css | 20 +++
public/assets/js/eiffel.js | 185 ++++++++++++++++++++
src/app/eiffel/web.go | 125 ++-----------
src/app/template/parser/parser.go | 10 +-
templates/eiffel/_list-requirements.go.html | 12 ++
templates/eiffel/elicitation-page.go.html | 2 +-
translations/de.json | 20 +--
7 files changed, 242 insertions(+), 132 deletions(-)
create mode 100644 templates/eiffel/_list-requirements.go.html
diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css
index 6409912..fd395eb 100644
--- a/public/assets/css/styles.css
+++ b/public/assets/css/styles.css
@@ -1,3 +1,23 @@
@import "bootstrap.min.css";
/* TODO add sass file to import bootstrap and allow variable overrides */
+
+#eiffelRequirementsListWrapper {
+ max-height: 50rem;
+ overflow-y: scroll;
+}
+
+.eiffel-requirements-list-item {
+ border-radius: var(--bs-border-radius);
+ background-color: rgba(var(--bs-light-rgb), 1);
+ padding: 0.5rem;
+ margin-bottom: 0.5rem;
+ margin-top: 0.5rem;
+ border: 1px solid var(--bs-light-border-subtle);
+ cursor: pointer;
+ transition: all 200ms ease-in-out;
+}
+
+.eiffel-requirements-list-item:hover {
+ background-color: rgba(var(--bs-light-rgb), 0.1);
+}
\ No newline at end of file
diff --git a/public/assets/js/eiffel.js b/public/assets/js/eiffel.js
index 7500a7f..e1626a5 100644
--- a/public/assets/js/eiffel.js
+++ b/public/assets/js/eiffel.js
@@ -1,9 +1,21 @@
+const EiffelMaxRequirementsInLocalStorage = 420;
+
document.addEventListener('DOMContentLoaded', registerDynamicFocuses);
document.addEventListener('htmx:afterSettle', registerDynamicFocuses);
+document.addEventListener('DOMContentLoaded', initRequirementsList);
+document.addEventListener('htmx:afterSettle', initRequirementsList);
+
document.addEventListener('DOMContentLoaded', autoResizeInput);
document.addEventListener('htmx:afterSettle', autoResizeInput);
+document.addEventListener('DOMContentLoaded', registerOutputEmptyBtn);
+document.addEventListener('htmx:afterSettle', registerOutputEmptyBtn);
+
+document.addEventListener('htmx:afterRequest', requirementParsed);
+document.addEventListener('newRequirementEvent', newRequirement);
+document.addEventListener('emptyRequirementsEvent', emptyRequirements);
+
registerFocuses();
registerShortcuts();
@@ -194,6 +206,29 @@ function copyRequirementToClipboard() {
});
}
+function registerOutputEmptyBtn() {
+ const outputEmptyBtn = document.getElementById('eiffelRequirementsEmpty');
+ if (!outputEmptyBtn || outputEmptyBtn.dataset.eiffelStatus === 'setup') return;
+
+ outputEmptyBtn.addEventListener('click', function () {
+ document.dispatchEvent(new CustomEvent('emptyRequirementsEvent'));
+ });
+
+ outputEmptyBtn.dataset.eiffelStatus = 'setup';
+}
+
+function copyOutputToClipboard(event) {
+ const target = event.target;
+ if (!target) return;
+
+ const output = target.innerText;
+
+ return navigator.clipboard.writeText(output)
+ .catch(() => {
+ alert('Sorry, your browser blocked copying the output to the clipboard. Try to copy manually.');
+ });
+}
+
function clearElicitationForm() {
const elicitationForm = document.getElementById('eiffelElicitationForm');
if (!elicitationForm) return;
@@ -226,4 +261,154 @@ function autoResizeInput() {
input.dataset.eiffelStatus = 'setup';
});
+}
+
+function requirementParsed(event) {
+ const xhr = event.detail.xhr;
+ if (!xhr) return;
+
+ const responseHeaders = xhr.getResponseHeader('ParsingSuccessEvent');
+ if (!responseHeaders) return;
+
+ // base64 decode the response header and parse it as JSON
+ event = JSON.parse(new TextDecoder().decode(base64ToBytes(responseHeaders)));
+ if (!event) return;
+
+ const parsingSuccessEvent = event.parsingSuccessEvent;
+ if (!parsingSuccessEvent) return;
+
+ const requirement = parsingSuccessEvent.requirement;
+ if (!requirement) return;
+
+ const timestamp = Date.now();
+ let key = `eiffel-requirement-${timestamp}`;
+ localStorage.setItem(key, requirement);
+
+ document.dispatchEvent(new CustomEvent('newRequirementEvent', {
+ detail: {
+ requirement: requirement,
+ key: key
+ }
+ }));
+}
+
+function newRequirement(event) {
+ const requirement = event.detail.requirement;
+ const key = event.detail.key;
+ if (!requirement) return;
+
+ const requirementList = document.querySelector('#eiffelRequirementsListWrapper ul');
+ if (!requirementList) return;
+
+ const firstListItem = requirementList.querySelector('ul > li.eiffel-requirements-list-item')
+ if (!firstListItem) return;
+
+ const newListItem = firstListItem.cloneNode(true);
+ newListItem.innerText = requirement;
+ newListItem.dataset.eiffelRequirementKey = key;
+ newListItem.addEventListener('click', copyOutputToClipboard);
+ requirementList.prepend(newListItem);
+
+ if (!firstListItem.dataset.eiffelRequirementKey) {
+ firstListItem.classList.add('d-none');
+ }
+}
+
+function initRequirementsList() {
+ const requirementListWrapper = document.getElementById('eiffelRequirementsListWrapper');
+ if (!requirementListWrapper || requirementListWrapper.dataset.eiffelStatus === 'setup') return;
+
+ let items = {};
+
+ // get all items from local storage
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (!key.startsWith('eiffel-requirement-')) continue;
+
+ const requirement = localStorage.getItem(key);
+ if (!requirement) continue;
+
+ items[key] = requirement;
+ }
+
+ // sort items ascending by timestamp (from key)
+ const sortedKeys = Object.keys(items).sort((a, b) => {
+ const aTimestamp = parseInt(a.replace('eiffel-requirement-', ''));
+ const bTimestamp = parseInt(b.replace('eiffel-requirement-', ''));
+
+ return aTimestamp - bTimestamp;
+ });
+ const sortedItems = {};
+ sortedKeys.forEach(key => {
+ sortedItems[key] = items[key];
+ });
+ items = sortedItems;
+
+ // clean up old items
+ items = cleanRequirementsList(items, EiffelMaxRequirementsInLocalStorage);
+
+ // call newRequirement for each item
+ Object.keys(items).forEach(key => {
+ document.dispatchEvent(new CustomEvent('newRequirementEvent', {
+ detail: {
+ requirement: items[key],
+ key: key
+ }
+ }));
+ });
+
+ requirementListWrapper.dataset.eiffelStatus = 'setup';
+}
+
+// cleanup oldest items if there are more than max items
+// expects items to be an object with key => value pairs that is sorted ascending by timestamp (from key)
+// returns the cleaned items object that was passed in
+function cleanRequirementsList(items, max) {
+ const keys = Object.keys(items);
+ // return early if there are less than max items
+ if (keys.length <= max) return items;
+
+ // delete the oldest items
+ const keysToDelete = keys.slice(0, keys.length - max);
+ keysToDelete.forEach(key => {
+ localStorage.removeItem(key);
+ });
+ console.info(`Removed ${keysToDelete.length} requirements from local storage.`);
+
+ // remove the deleted items from the list
+ keysToDelete.forEach(key => {
+ delete items[key];
+ });
+
+ return items;
+}
+
+function emptyRequirements() {
+ const requirementList = document.querySelector('#eiffelRequirementsListWrapper ul');
+ if (!requirementList) return;
+
+ const itemNodes = requirementList.querySelectorAll('li.eiffel-requirements-list-item');
+ if (!itemNodes) return;
+
+ const items = {};
+ itemNodes.forEach(itemNode => {
+ if (!itemNode.dataset.eiffelRequirementKey) return;
+ items[itemNode.dataset.eiffelRequirementKey] = itemNode.innerText;
+ });
+
+ cleanRequirementsList(items, 0);
+
+ itemNodes.forEach((itemNode) => {
+ if (!itemNode.dataset.eiffelRequirementKey) {
+ itemNode.classList.remove('d-none');
+ return;
+ }
+
+ itemNode.remove();
+ });
+}
+
+function base64ToBytes(base64) {
+ const binString = atob(base64);
+ return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
\ No newline at end of file
diff --git a/src/app/eiffel/web.go b/src/app/eiffel/web.go
index 17e3e08..fba9462 100644
--- a/src/app/eiffel/web.go
+++ b/src/app/eiffel/web.go
@@ -1,6 +1,7 @@
package eiffel
import (
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -15,7 +16,6 @@ import (
"github.com/org-harmony/harmony/src/core/util"
"github.com/org-harmony/harmony/src/core/web"
"net/http"
- "path/filepath"
"strings"
)
@@ -35,10 +35,6 @@ var (
ErrTemplateNotFound = errors.New("eiffel.elicitation.template.not-found")
// ErrTemplateVariantNotFound will be displayed to the user if the template variant could not be found.
ErrTemplateVariantNotFound = errors.New("eiffel.elicitation.template.variant.not-found")
- // ErrInvalidFilepath will be displayed to the user if the filepath is invalid.
- ErrInvalidFilepath = errors.New("eiffel.elicitation.output.file.path-invalid")
- // ErrCouldNotCreateFile will be displayed to the user if the file could not be created.
- ErrCouldNotCreateFile = errors.New("eiffel.elicitation.output.file.create-failed")
)
// TemplateDisplayType specifies how a rule should be displayed in the UI.
@@ -65,8 +61,6 @@ type TemplateFormData struct {
// SegmentMap is a map of rule names to their corresponding segment value.
// This is used to fill the segments with their values after parsing.
SegmentMap map[string]string
- // OutputFormData is the form data for the output directory and file.
- OutputFormData web.BaseTemplateData
}
// SearchTemplateData contains templates to render as search results and a flag indicating if the query was too short.
@@ -75,11 +69,8 @@ type SearchTemplateData struct {
QueryTooShort bool
}
-// OutputFormData is the form data for the output directory and file search form.
-// It is used to fill the form with the previously selected values.
-type OutputFormData struct {
- OutputDir string
- OutputFile string
+type HTMXTriggerParsingSuccessEvent struct {
+ ParsingSuccessEvent *parser.ParsingResult `json:"parsingSuccessEvent"`
}
// RegisterController registers the controllers as well as the navigation and event listeners.
@@ -102,9 +93,6 @@ func RegisterController(appCtx *hctx.AppCtx, webCtx *web.Ctx) {
router.Get("/eiffel/elicitation/{templateID}", elicitationTemplate(appCtx, webCtx, true).ServeHTTP)
router.Get("/eiffel/elicitation/{templateID}/{variant}", elicitationTemplate(appCtx, webCtx, false).ServeHTTP)
router.Post("/eiffel/elicitation/{templateID}/{variant}", parseRequirement(appCtx, webCtx, cfg).ServeHTTP)
- router.Post("/eiffel/elicitation/output/search", outputSearchForm(appCtx, webCtx, cfg).ServeHTTP)
- router.Post("/eiffel/elicitation/output/dir/search", outputSearchDir(appCtx, webCtx, cfg).ServeHTTP)
- router.Post("/eiffel/elicitation/output/file/search", outputFileSearch(appCtx, webCtx, cfg).ServeHTTP)
}
func subscribeEvents(appCtx *hctx.AppCtx) {
@@ -185,7 +173,7 @@ func renderElicitationPage(io web.IO, data TemplateFormData, success []string, e
"eiffel/elicitation-page.go.html",
"eiffel/_elicitation-template.go.html",
"eiffel/_form-elicitation.go.html",
- "eiffel/_form-output-file.go.html",
+ "eiffel/_list-requirements.go.html",
)
}
@@ -274,8 +262,6 @@ func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handle
templateID := web.URLParam(request, "templateID")
variant := web.URLParam(request, "variant")
- outputDir := BuildDirPath(cfg.Output.BaseDir, request.FormValue("elicitationOutputDir"))
- outputFileRaw := request.FormValue("elicitationOutputFile")
formData, err := TemplateFormFromRequest(
ctx,
@@ -290,14 +276,9 @@ func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handle
return io.InlineError(err)
}
- if outputFileRaw == "" {
- outputFileRaw = formData.Template.Name
- }
- outputFile := BuildFilename(outputFileRaw)
-
segmentMap, err := SegmentMapFromRequest(request, len(formData.Variant.Rules))
if err != nil {
- return io.InlineError(web.ErrInternal)
+ return io.InlineError(web.ErrInternal, err)
}
formData.SegmentMap = segmentMap
@@ -312,24 +293,15 @@ func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handle
}
if parsingResult.Ok() {
- usr := user.MustCtxUser(ctx)
- csv, created, err := CreateIfNotExists(outputDir, outputFile, 0750)
- if err != nil {
- return io.InlineError(ErrCouldNotCreateFile, err)
- }
-
- writer := CSVWriter{file: csv}
- if created {
- err := writer.WriteHeaderRow()
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
- }
-
- err = writer.WriteRow(parsingResult, usr)
+ triggerEvent := &HTMXTriggerParsingSuccessEvent{ParsingSuccessEvent: &parsingResult}
+ triggerEventJSON, err := json.Marshal(triggerEvent)
if err != nil {
return io.InlineError(web.ErrInternal, err)
}
+
+ // Using HX-Trigger header here is not possible because we could potentially use unicode characters in our response.
+ // To escape them we have to use base64 encoding and a custom header.
+ io.Response().Header().Set("ParsingSuccessEvent", base64.URLEncoding.EncodeToString(triggerEventJSON))
}
formData.CopyAfterParse = CopyAfterParseSetting(request, sessionStore, false)
@@ -337,78 +309,3 @@ func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handle
return io.Render(web.NewFormData(formData, s, err), "eiffel.elicitation.form", "eiffel/_form-elicitation.go.html")
})
}
-
-func outputSearchForm(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
- return web.NewController(appCtx, webCtx, func(io web.IO) error {
- request := io.Request()
- err := request.ParseForm()
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
-
- rawDir := request.FormValue("output-dir")
- dir := BuildDirPath(cfg.Output.BaseDir, rawDir)
- rawFile := request.FormValue("output-file")
- file := BuildFilename(rawFile)
-
- path := filepath.Join(dir, file)
- if path == "" {
- return io.InlineError(ErrInvalidFilepath)
- }
-
- csv, created, err := CreateIfNotExists(dir, file, 0750)
- if err != nil {
- return io.InlineError(ErrCouldNotCreateFile, err)
- }
-
- if created {
- writer := CSVWriter{file: csv}
- err = writer.WriteHeaderRow()
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
- }
-
- return io.Render(web.NewFormData(OutputFormData{
- OutputDir: rawDir,
- OutputFile: rawFile,
- }, []string{"eiffel.elicitation.output.file.success"}), "eiffel.elicitation.output-file.form", "eiffel/_form-output-file.go.html")
- })
-}
-
-func outputSearchDir(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
- return web.NewController(appCtx, webCtx, func(io web.IO) error {
- request := io.Request()
- err := request.ParseForm()
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
-
- query := request.FormValue("output-dir")
- dirs, err := DirSearch(cfg.Output.BaseDir, query)
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
-
- return io.Render(dirs, "eiffel.elicitation.output.dir.search-result", "eiffel/_output-dir-search-result.go.html")
- })
-}
-
-func outputFileSearch(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler {
- return web.NewController(appCtx, webCtx, func(io web.IO) error {
- request := io.Request()
- err := request.ParseForm()
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
-
- query := request.FormValue("output-file")
- dir := request.FormValue("output-dir")
- files, err := FileSearch(BuildDirPath(cfg.Output.BaseDir, dir), query)
- if err != nil {
- return io.InlineError(web.ErrInternal, err)
- }
-
- return io.Render(files, "eiffel.elicitation.output.file.search-result", "eiffel/_output-file-search-result.go.html")
- })
-}
diff --git a/src/app/template/parser/parser.go b/src/app/template/parser/parser.go
index afae82c..d8c3f66 100644
--- a/src/app/template/parser/parser.go
+++ b/src/app/template/parser/parser.go
@@ -28,12 +28,12 @@ type ParsingSegment struct {
// ParsingResult is the result of parsing a requirement using a template.
type ParsingResult struct {
- TemplateID string
+ TemplateID string `json:"templateID,omitempty"`
TemplateType string
- TemplateVersion string
- TemplateName string
- VariantName string
- Requirement string
+ TemplateVersion string `json:"templateVersion,omitempty"`
+ TemplateName string `json:"templateName,omitempty"`
+ VariantName string `json:"variantName,omitempty"`
+ Requirement string `json:"requirement,omitempty"`
Errors []ParsingLog
Warnings []ParsingLog
Notices []ParsingLog
diff --git a/templates/eiffel/_list-requirements.go.html b/templates/eiffel/_list-requirements.go.html
new file mode 100644
index 0000000..c96526c
--- /dev/null
+++ b/templates/eiffel/_list-requirements.go.html
@@ -0,0 +1,12 @@
+{{ define "eiffel.requirements.list" }}
+ <h3>{{ t "eiffel.output.recent.title" }}</h3>
+ <p>{{ t "eiffel.output.recent.description" }}</p>
+ <div id="eiffelRequirementsListWrapper">
+ <ul class="list-unstyled">
+ <li class="eiffel-requirements-list-item">
+ <b>{{ t "eiffel.output.recent.empty" }}</b>
+ </li>
+ </ul>
+ </div>
+ <button class="btn btn-outline-secondary w-100 mt-2" id="eiffelRequirementsEmpty">{{ t "eiffel.output.recent.empty-button" }}</button>
+{{ end }}
\ No newline at end of file
diff --git a/templates/eiffel/elicitation-page.go.html b/templates/eiffel/elicitation-page.go.html
index 323e6f2..0865ab6 100644
--- a/templates/eiffel/elicitation-page.go.html
+++ b/templates/eiffel/elicitation-page.go.html
@@ -15,7 +15,7 @@
</div>
<div class="col-4 eiffel-requirements">
- {{ template "eiffel.elicitation.output-file.form" .Data.Form.OutputFormData }}
+ {{ template "eiffel.requirements.list" . }}
</div>
</div>
{{ end }}
\ No newline at end of file
diff --git a/translations/de.json b/translations/de.json
index cdf3107..26c5943 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -128,18 +128,6 @@
"value-single-select": "Ein Wert aus",
"copy-and-clear": "Kopieren und leeren"
},
- "output": {
- "title": "Ausgabedatei festlegen (Alt + O)",
- "file": "Ausgabedatei",
- "file.success": "Ausgabedatei für Erfassung festgelegt.",
- "file.save": "Ausgabedatei für Erfassung speichern",
- "file.help": "Die Ausgabedatei wird automatisch erstellt, wenn sie nicht existiert. Anforderungen werden angehängt. Die \".csv\"-Dateierweiterung wird automatisch hinzugefügt.",
- "directory": "Ausgabeverzeichnis",
- "directory.help": "Unterverzeichnisse sollten durch \"\/\" getrennt werden. Nicht existierende Verzeichnisse werden automatisch erstellt.",
- "file.info": "Legen Sie eine Ausgabedatei fest, in die Anforderungen nach der Prüfung automatisch geschrieben werden. Somit gehen keine Anforderungen verloren.",
- "file.path-invalid": "Der Pfad ist ungültig. Bitte überprüfen Sie den Pfad und versuchen Sie es erneut.",
- "file.create-failed": "Die Datei konnte nicht erstellt werden. Bitte überprüfen Sie den Pfad und die Dateiberechtigungen und versuchen Sie es erneut."
- },
"template": {
"search": {
"title": "Schablone suchen",
@@ -166,6 +154,14 @@
"settings": "Einstellungen",
"copy-after-parse": "Anforderung nach erfolgreicher Prüfung automatisch kopieren und das Formular leeren (manuell: Alt + K)"
}
+ },
+ "output": {
+ "recent": {
+ "title": "Zuletzt erfasste Anforderungen",
+ "description": "Ihre 420 letzten erfassten Anforderungen werden auf Ihrem Gerät gespeichert und hier angezeigt. Sie können diese Anforderungen durch Klicken kopieren.",
+ "empty": "Es wurden noch keine Anforderungen erfasst.",
+ "empty-button": "Letzte Anforderungen leeren"
+ }
}
},
"harmony": {
--
GitLab