// one instance should only be used for one page at a time sclass HCRUD extends HAbstractRenderable { HCRUD_Data data; // normally an instance of HCRUD_Concepts (#1026002) // yeah these fields are a mess... bool mutationRights = true; bool allowCreateOrDelete = true; bool allowCreate = true; bool allowEdit = true; bool singleton; bool cmdsLeft; // put commands on the left instead of the right bool showEntryCountInTitle; bool allowFieldRenaming; int defaultTextFieldCols = 80; int valueDisplayLength = 1000; S tableClass; // CSS class for table S formTableClass; // CSS class for form S checkBoxClass = "crud_chkbox"; S customTitle; bool showTextFieldsAsAutoExpandingTextAreas; Set unshownFields; // not shown in table or form Set uneditableFields; // not shown in form Set unlistedFields; // not shown in table bool showCheckBoxes; bool cleanItemIDs; bool haveJQuery, haveSelectizeJS, haveSelectizeClickable; bool needsJQuery; // unused bool paginate; bool sortable; bool buttonsOnTop, buttonsOnBottom = true; bool duplicateInNewTab; S formID = "crudForm"; bool showQuickSaveButton; // needs hnotificationPopups() bool enableMultiSelect = true; // enables shift+click on check boxes. needs haveJQuery bool cellColumnToolTips; // give each table cell a tooltip showing its column name bool showSearchField; S searchQuery; SS params; new HTMLPaginator paginator; // sort options S sortByField = "id"; S sortParameter = "sort"; bool descending; int flexibleLengthListLeeway = 5; // how many empty rows to display at the end of each flexible length list // set internally after update/create or from "selectObj" param to highlight an item in the list (or show only that item) O objectIDToHighlight; bool showOnlySelected; S fieldPrefix = "f_"; int entryCount; *() {} *(HCRUD_Data *data) {} *(S *baseLink, HCRUD_Data *data) {} S newLink() { ret appendQueryToURL(baseLink, cmd := "new"); } S deleteLink(O id) { ret appendQueryToURL(baseLink, "delete_" + id, 1); } S editLink(O id) { ret appendQueryToURL(baseLink, edit := id); } S duplicateLink(O id) { ret appendQueryToURL(baseLink, duplicate := id); } void setParams(SS params) { this.params = params; if (objectIDToHighlight == null) objectIDToHighlight = params.get("selectObj"); if (eq("1", params.get("showOnlySelected"))) set showOnlySelected; } // also handles commands if withCmds=true // you probably want to call renderPage() instead to handle all commands S render(bool withCmds, SS params) { //print("HCRUD render"); setParams(params); if (!withCmds) ret renderTable(false); try answer handleCommands(params); ret renderMsgs(params) + divUnlessEmpty(nav()) + renderTable(withCmds); } swappable S nav() { new LS l; if (actuallyAllowCreate()) l.add(ahref(newLink(), "New " + itemName())); if (showSearchField) l.add(hInlineSearchForm("search", searchQuery, "")); ret joinWithVBar(l); } S handleCommands(SS params) { new LS msgs; if (eqGet(params, "action", "create")) { if (!actuallyAllowCreate()) fail("Creating objects not allowed"); processRenames(params); O id = data.createObject(preprocessUpdateParams(params), fieldPrefix); msgs.add(itemName() + " created (ID: " + id + ")"); objectIDToHighlight = id; } if (eqGet(params, "action", "update")) { if (!actuallyAllowEdit()) fail("Editing objects not allowed"); S id = params.get("id"); processRenames(params); msgs.add(data.updateObject(id, preprocessUpdateParams(params), fieldPrefix)); objectIDToHighlight = id; } LS toDeleteList = keysDeprefixNemptyValue(params, "delete_"); if (eq(params.get("bulkAction"), "deleteSelected")) toDeleteList.addAll(keysDeprefixNemptyValue(params, "obj_")); for (S toDelete : toDeleteList) { if (!actuallyAllowDelete()) fail("Deleting objects not allowed"); msgs.add(data.deleteObject(toDelete)); } ret empty(msgs) ? "" : refreshAfterCommand(params, msgs); } swappable S refreshAfterCommand(SS params, LS msgs) { S redirectAfterSave = mapGet(params, "redirectAfterSave"); if (nempty(redirectAfterSave)) ret hrefresh(redirectAfterSave); ret refreshWithMsgs(msgs, anchor := objectIDToHighlight == null ? null : "obj" + objectIDToHighlight, params := objectIDToHighlight == null ? null : litmap(selectObj := objectIDToHighlight)); } S encodeField(S s) { ret or(data.fieldNameToHTML(s), s); } // in table swappable S renderValue(S field, O value) { if (value cast HTML) ret value.html; value = deref(value); if (value cast SecretValue) ret hhiddenStuff(renderValue_inner(value!)); ret renderValue_inner(value); } swappable S renderValue_inner(O value) { if (value cast Bool) ret yesNo_short(value); ret htmlEncode_nlToBr_withIndents(shorten(valueDisplayLength, strOrEmpty(value))); } S renderTable(bool withCmds) { ret renderTable(withCmds, data.list()); } S valueToSortable(O value) { if (value cast HTML) ret value!; ret strOrNull(value); } // l = list of maps as it comes from data object S renderTable(bool withCmds, L l) { //print("HCRUD renderTable"); entryCount = l(l); if (empty(l)) ret p("No entries"); if (!eq(data.defaultSortField(), pair(sortByField, descending))) { if (nempty(sortByField)) { print("Sorting " + nEntries(l) + " by " + sortByField); l = sortByTransformedMapKey_alphaNum valueToSortable(l, sortByField); } if (descending) l = reversed(l); } // l2 is the rendered map. use keyEncoding to get from l keys to l2 keys new SS keyEncoding; L l2 = lazyMap(l, _map -> { O id = itemID(_map); ret data.new Item(id) { public MapSO calcFullMap() { MapSO map = _map; map = mapMinusKeys(map, joinSets(unshownFields, unlistedFields)); MapSO map2 = postProcessTableRow(map, mapToMap( (key, value) -> pair(mapPut_returnValue(keyEncoding, key, encodeField(key)), renderValue(key, value)), map)); if (singleton) map2.remove(encodeField(data.idField())); // don't show ID in table in singleton mode if (withCmds) map2 = addCmdsToTableRow(map, map2); // add anchor to row map2.put(firstKey(map2), aname("obj" + id, firstValue(map2))); ret map2; } }; }); new LS out; if (paginate) { paginator.processParams(params); paginator.baseLink = addParamsToURL(baseLink, filterKeys keepParamInPagination(params)); paginator.max = l(l2); out.add(divUnlessEmpty(paginator.renderNav())); L l3 = subListOrFull(l2, paginator.visibleRange()); //printVars_str(+objectIDToHighlight, visible := l(l3), first := first(l3), firstID := mapToID(first(l3))); // if highlighted object is not on page or user wants to see only this object, show only this object if (objectIDToHighlight != null && (showOnlySelected || !any isHighlighted(l3))) { l3 = llNonNulls(firstThat isHighlighted(l2)); //printVars_str(+objectIDToHighlight, total := l(l2), found := l(l3)); } l2 = l3; } new SS replaceHeaders; if (sortable && !singleton) for (S key, html : keyEncoding) { bool sortedByField = eq(sortByField, key); bool showDescendingLink = sortedByField && !descending; S htmlOld = html; if (sortedByField) { S title = showDescendingLink ? "Click here to sort descending" : "Click here to sort ascending"; S titleSorted = "Sorted by this field (" + (descending ? "descending" : "ascending") + ")"; title = titleSorted + ". " + title; html = span_title(title, unicode_downOrUpPointingTriangle(descending)) + " " + html; } S sortLink = appendQueryToURL(baseLink, sortParameter, showDescendingLink ? "-" + key : key); replaceHeaders.put(htmlOld, /*html + " " + ahref(sortLink, unicode_smallDownOrUpPointingTriangle(showDescendingLink), +title)*/ ahref(sortLink, html)); } Map paramsByColName = null; if (cellColumnToolTips) { paramsByColName = new Map; for (MapSO map : l2) for (S key : keys(map)) if (!paramsByColName.containsKey(key)) paramsByColName.put(key, litobjectarray(title := nullIfEmpty(htmldecode_dropTagsAndComments(key)))); } out.add(hpostform( htmlTable2_noHtmlEncode(l2, paramsPlus(tableParams(), +replaceHeaders, +paramsByColName)) + (!withCmds || !showCheckBoxes ? "" : "\n" + divUnlessEmpty(renderBulkCmds())), action := baseLink)); if (showCheckBoxes && haveJQuery && enableMultiSelect) out.add(hCheckBoxMultiSelect_v2()); ret lines_rtrim(out); } O mapToID(MapSO item) { ret item == null ?: dropAllTags(strOrNull(item.get(encodeField(idField())))); } // item is after encodeField bool isHighlighted(MapSO item) { O id = mapToID(item); //print("isHighlighted ID: " + toStringWithClass(id) + " / " + toStringWithClass(objectIDToHighlight)); ret eq(id, objectIDToHighlight); } swappable S renderBulkCmds() { ret "Bulk action: " + hselect("bulkAction", litorderedmap("" := "", "deleteSelected" := "Delete selected")) + " " + hsubmit("OK", onclick := "return confirm('Are you sure?')"); } MapSO addCmdsToTableRow(MapSO map, MapSO map2) { if (showCheckBoxes) { O id = itemID(map); map2.put(checkBoxKey(), hcheckbox("obj_" + id, false, title := "Select this object for a bulk action", class := checkBoxClass)); map2 = putKeysFirst(map2, checkBoxKey()); } map2.put(cmdsKey(), renderCmds(map)); if (cmdsLeft) map2 = putKeysFirst(map2, cmdsKey()); ret map2; } /*swappable*/ O[] tableParams() { ret litparams( tdParams := litparams(valign := "top"), tableParams := litparams(class := tableClass)); } S renderForm(MapSO map) { temp tempSetTL(htmlencode_forParams_useV2, true); //print("renderForm: filteredFields=" + data.filteredFields()); map = mapMinusKeys(map, joinSets(unshownFields, uneditableFields, data.filteredFields())); MapSO mapWithoutID = mapWithoutKey(map, data.idField()); LLS matrix = map(mapWithoutID, (field, value) -> { S help = data.fieldHelp(field); ret ll( allowFieldRenaming ? hinputfield("rename_" + field, field, class := "field-rename", style := "border: none; text-align: right", title := "Edit this to rename field " + quote(field) + " or clear to delete field") : encodeField(field), addHelpText(help, renderInput(field, value)) ); }); massageFormMatrix(map, matrix); ret htableRaw_valignTop(matrix , empty(formTableClass) ? litparams(border := 1, cellpadding := 4) : litparams(class := formTableClass)); } S renderInput(S field, O value) { S name = fieldPrefix + field; ret renderInput(name, data.getRenderer(field, value), value); } S renderInput(S name, HCRUD_Data.Renderer r, O value) { //print("Renderer for " + name + ": " + r); if (r != null) value = r.preprocessValue(value); S meta = r == null ? "" : renderMetaInfo(r.metaInfo, name); // switch by renderer type if (r cast HCRUD_Data.AceEditor) { //ret meta + hAceEditor(strOrEmpty(value), style := "width: " + r.cols + "ch; height: " + r.rows + "em", +name); HTMLAceEditor ace = new(strOrEmpty(value)); ace.name = name; ace.divParams.put(style := "width: " + r.cols + "ch; height: " + r.rows + "em"); customizeACEEditor(ace); ret meta + ace.headStuff() + ace.html(); } if (r cast HCRUD_Data.TextArea) ret meta + htextarea(strOrEmpty(value), +name, cols := r.cols, rows := r.rows); if (r cast HCRUD_Data.TextField) ret meta + renderTextField(name, strOrEmpty(value), r.cols); if (r cast HCRUD_Data.ComboBox) ret meta + renderComboBox(name, r.valueToEntry(value), r.entries, r.editable); if (r cast HCRUD_Data.DynamicComboBox) ret meta + renderDynamicComboBox(name, r.valueToEntry(value), r.info, r.editable, r.url); if (r cast HCRUD_Data.CheckBox) //ret hcheckbox(name, isTrue(value)); ret meta + htrickcheckboxWithText(name, "", isTrue(value)); if (r cast HCRUD_Data.FlexibleLengthList) { L list = cast value; new LS rows; int n = l(list)+flexibleLengthListLeeway; for i to n: { O item = _get(list, i); //print("Item: " + item); rows.add(tr(td(i+1 + ".", align := "right") + td(renderInput(name + "_" + i, r.itemRenderer, item)))); } ret meta + htag table(lines(rows)); } if (r instanceof HCRUD_Data.NotEditable) ret "Not editable"; ret renderInput_default(name, value); } swappable void customizeACEEditor(HTMLAceEditor ace) {} S renderMetaInfo(S metaInfo, S name) { if (empty(metaInfo)) ret ""; ret hhidden("metaInfo_" + dropPrefix(fieldPrefix, name), metaInfo); } S renderInput_default(S name, O value) { ret renderTextField(name, strOrEmpty(value), defaultTextFieldCols); } S renderTextField(S name, S value, int cols) { if (showTextFieldsAsAutoExpandingTextAreas) { ret htextarea(value, +name, class := "auto-expand", style := "width: " + cols + "ch", autofocus := eq(mapGet(params, "autofocus"), name) ? html_valueLessParam() : null, onkeydown := jquery_submitFormOnCtrlEnter()); } ret htextfield(name, value, size := cols, style := "font-family: monospace"); } S renderNewForm() { ret renderNewForm(data.emptyObject()); } // pre-populate fields from request parameters S renderNewFormWithParams(SS params) { SS filteredMap = subMapStartingWith_dropPrefix(params, fieldPrefix); MapSO map = joinMaps(data.emptyObject(), (Map) filteredMap); data.rawFormValues = params; // pre-populate list fields for (S key, value : filteredMap) { LS l = splitAt(key, "_"); if (l(l) == 2) { S field = first(l); int idx = parseInt(second(l)); LS list = cast map.get(field); if (!list instanceof ArrayList) map.put(field, list = new L); listPut(list, idx, value); } } ret renderNewForm(map); } S renderNewForm(MapSO map) { //printStruct("renderNewForm", map); S buttons = p(hsubmit("Create")); ret hpostform( hhidden("action", "create") + formExtraHiddens() + stringIf(buttonsOnTop, buttons) + renderForm(map) + stringIf(buttonsOnBottom, buttons), paramsPlus(formParameters(), action := baseLink)); } swappable O[] formParameters() { ret litparams(id := formID); } S idField() { ret data.idField(); } S renderEditForm(S id) { if (!actuallyAllowEdit()) ret "Can't edit objects in this table"; if (!data.objectCanBeEdited(id)) ret htmlEncode2("Object " + id + " can't be edited"); MapSO map = data.getObjectForEdit(id); if (map == null) ret htmlEncode2("Entry " + id + " not found"); S onlyFields = mapGet(params, "onlyFields"); if (nempty(onlyFields)) map = onlyKeys(map, itemPlus(idField(), tok_identifiersOnly(onlyFields))); S buttons = p_vbar( hsubmit("Save changes"), !showQuickSaveButton ? "" : hbuttonOnClick_noSubmit("Save & keep editing", [[ $.ajax({ type: 'POST', url: $('#crudForm').attr('action'), data: $('#crudForm').serialize(), success: function(response) { successNotification("Saved"); }, }).error(function() { errorNotification("Couldn't save"); }); ]]), deleteObjectHTML(id)); ret hpostform( hhidden("action", "update") + formExtraHiddens() + hhidden(+id) + p("Object ID: " + htmlEncode2(id)) + + stringIf(buttonsOnTop, buttons) + renderForm(map) + stringIf(buttonsOnBottom, buttons), paramsPlus(formParameters(), action := baseLink + "#obj" + id)); } S renderPage(SS params) { //print("HCRUD renderPage"); setParams(params); try answer handleComboSearch(params); if (eqGet(params, "cmd", "new")) { if (!actuallyAllowCreate()) ret "Can't create objects in ths table"; ret frame(customTitleOr("New " + itemName()), renderNewFormWithParams(params)); } if (nempty(params.get("edit"))) ret frame("Edit " + itemName(), renderEditForm(params.get("edit"))); if (nempty(params.get("duplicate"))) ret frame("New " + itemName(), renderNewForm(data.getObjectForDuplication(params.get("duplicate")))); S rendered = render(mutationRights, params); // handle commands, render list S title = null; if (singleton) title = ahref(baseLink, firstToUpper(data.itemName())); else { if (objectIDToHighlight != null) title = data.titleForObjectID(objectIDToHighlight); if (empty(title)) title = (showEntryCountInTitle ? n2(entryCount) + " " : "") + ahref(baseLink, firstToUpper(data.itemNamePlural())); } ret frame(customTitleOr(title), rendered); } HCRUD makeFrame(MakeFrame makeFrame) { super.makeFrame(makeFrame); this; } S cmdsKey() { ret ""; } S checkBoxKey() { ret ""; } S itemName() { ret data.itemName(); } swappable MapSO postProcessTableRow(MapSO data, MapSO rendered) { ret rendered; } O itemID(MapSO item) { O id = mapGet(item, data.idField()); // getVarOpt decodes HTML record if (cleanItemIDs) id = htmlDecode_dropTags(strOrNull(getVarOpt(id))); ret id; } long itemIDAsLong(MapSO item) { ret parseLong(itemID(item)); } // return list of HTMLs for commands in pop down button swappable LS additionalCmds(MapSO item) { null; } swappable S renderCmds(MapSO item) { O id = itemID(item); LS additionalCmds = additionalCmds(item); ret joinNemptiesWithVBar( !actuallyAllowEdit() || !data.objectCanBeEdited(id) ? null : ahref(editLink(id), "EDIT"), deleteObjectHTML(id), !actuallyAllowCreate() ? null : targetBlankIf(duplicateInNewTab, duplicateLink(id), "dup", title := "duplicate"), empty(additionalCmds) ? null : hPopDownButton(additionalCmds) ); } bool actuallyAllowCreate() { ret !singleton && allowCreateOrDelete && allowCreate; } bool actuallyAllowEdit() { ret allowCreateOrDelete && allowEdit; } bool actuallyAllowDelete() { ret !singleton && allowCreateOrDelete; } // e.g. for adding rows to edit/create form swappable void massageFormMatrix(MapSO map, LLS matrix) { } S renderComboBox(S name, S value, LS entries, bool editable) { if (haveSelectizeJS) { // coolest option - use selectize.js S id = aGlobalID(); ret hselect_list(entries, value, +name, +id) + hjs("$('#" + id + "').selectize" + [[ ({ searchField: 'text', openOnFocus: true, dropdownParent: 'body', create: ]] + jsBool(editable) + [[ /*allowEmptyOption: true*/ ]] + unnull(moreSelectizeOptions2(name)) + [[ }); ]]) + selectizeLayoutFix(); } if (haveJQuery) { // make searchable list if JQuery is available // (functional, but looks quite poor on Firefox) // TODO: this seems to always be editable S id = aGlobalID(); ret tag datalist(mapToLines hoption(entries), +id) + tag input("", +name, list := id); } // standard non-searchable list if (editable) ret hinputfield(name, value); // no editable combo box possible without selectize.js ret hselect_list(entries, value, +name); } S renderDynamicComboBox(S name, S value, S info, bool editable, S url default null) { assertTrue(+haveSelectizeJS); S id = aGlobalID(); S ajaxURL = or2(url, baseLink); ret hselect_list(llNempties(value), value, +name, +id) + hjs("$('#" + id + "').selectize" + [[ ({ searchField: 'text', valueField: 'text', labelField: 'text', openOnFocus: true, dropdownParent: 'body', create: ]] + jsBool(editable) + [[, load: function(query, callback) { if (!query.length) return callback(); var data = { comboSearchInfo: ]] + jsQuote(info) + [[, comboSearchQuery: query }; console.log("Loading " + ]] + jsQuote(baseLink) + [[ + " with " + JSON.stringify(data)); $.ajax({ url: ]] + jsQuote(ajaxURL) + [[, type: 'GET', dataType: 'json', data: data, error: function() { console.log("Got error"); callback(); }, success: function(res) { //console.log("Got data: " + res); var converted = res.map(x => { return {text: x}; }); //console.log("Converted: " + converted); callback(converted); } }); } /*allowEmptyOption: true*/ ]] + moreSelectizeOptions2(name) + [[ }); ]]) + selectizeLayoutFix(); } void processSortParameter(SS params) { S sort = mapGet(params, sortParameter); //sortByField = null; if (nempty(sort)) if (startsWith(sort, "-")) { descending = true; sortByField = substring(sort, 1); } else { descending = false; sortByField = sort; } } S deleteObjectHTML(O id) { ret !actuallyAllowDelete() ? null : !data.objectCanBeDeleted(id) ? // TODO: custom msg! span_title("Object can't be deleted, either there are references to it or you are not authorized", htmlEncode2(unicode_DEL())) : ahrefWithConfirm( "Really delete item " + id + "?", deleteLink(id), htmlEncode2(unicode_DEL()), title := "delete"); } // helpText is also HTML S addHelpText(S helpText, S html) { ret empty(helpText) ? html : html + p(small(helpText), style := "text-align: right"); } S moreSelectizeOptions2(S name) { ret unnull(moreSelectizeOptions(name)) + (!haveSelectizeClickable ? "" : [[ , plugins: ['clickable'] , render: { option: function(item) { var id = item.text.match(/\d+/)[0]; return '
'+item.text+'' + '
' + '' + '
'; } } ]]); } swappable S moreSelectizeOptions(S name) { ret ""; } // deliver content for dynamic search in combo boxes S handleComboSearch(SS params) { S query = params.get("comboSearchQuery"); if (nempty(query)) { S info = params.get("comboSearchInfo"); ret jsonEncode_shallowLineBreaks(data.comboBoxSearch(info, query)); } null; } // for update or create swappable SS preprocessUpdateParams(SS params) { ret params; } void disableAllMutationRights() { mutationRights = allowCreateOrDelete = allowCreate = allowEdit = false; } swappable bool keepParamInPagination(S name) { ret eq(name, "search"); } S customTitleOr(S title) { ret or2(customTitle, or2(mapGet(params, "title"), title)); } swappable S selectizeLayoutFix() { // dirty CSS quick-fix (TODO: send only once) ret hcss(".selectize-input, .selectize-control { min-width: 300px }"); } S formExtraHiddens() { S redirectAfterSave = mapGet(params, "redirectAfterSave"); ret empty(redirectAfterSave) ? "" : hhidden(+redirectAfterSave); } void processRenames(SS params) { if (!allowFieldRenaming) ret; for (S key1 : keysList(params)) { S field = dropPrefixOrNull("rename_", key1); if (field == null) continue; S newName = trim(params.get(key1)); if (newName == null || eq(field, newName)) continue; print("Renaming " + field + " to " + or2(newName, "")); params.remove("rename_" + field); // Try to catch f_myField, metaInfo_myField, f_myField_1... S re = "^([^_]+_)" + regexpQuote(field) + "(_[^_]+)?$"; for (S key : keysList(params)) { LS groups = regexpGroups(re, key); if (groups != null) { S newKey = empty(newName) ? null : first(groups) + newName + unnull(second(groups)); mapPut(params, newKey, params.get(key)); params.put(key, ""); // this should delete stuff (if empty strings are converted to null) print("Renaming key: " + key + " => " + newKey); } } } } }