diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css index 6409912d04b48489bdce6d58aee3d0414d54513b..fd395eb49572759ad661bc5ad1cd5c89c7f8d6c7 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 7500a7ff11ee1899d106dee54bf4fd9562f6be20..e1626a59f6adbfa39585da702af0de0f406c5099 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 17e3e0814ffc83e6472fc1416833e2705e6df080..fba9462b85e6c3eba2b1c4bc9147547b40217727 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 afae82cb4d1aff8c88be07ea1192c6165b6451d6..d8c3f66be64adccd7445442cf81a7d05d78839da 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 0000000000000000000000000000000000000000..c96526cf92221cb0cd776ec7f3771a3696200ee9 --- /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 323e6f2109fd6d211c98858cf54c44ce62022331..0865ab616c4fa24103cbc1fc48f959b31482dc7a 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 cdf3107b314ee2dc95f886b7c3ed207737a9da4a..26c59430267dafe8d290d814be4944a050dce3e2 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": {