diff --git a/CHANGELOG.md b/CHANGELOG.md index 6677594cbce4b67f2097d9b097c76b1ad2930692..38b531183e76cc43ae00b14bb65c83a5753c1b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added (for new features, dependecies etc.) +* Displaying and interacting with the entity state. * Change password functionality for users of the internal user source. * Visually highlighted drop zones for properties and parents in the edit_mode. * two new field types for the form_elements module, `file` and `select`. See diff --git a/misc/entity_state_test_data.py b/misc/entity_state_test_data.py new file mode 100755 index 0000000000000000000000000000000000000000..72ffe278d0ad90cb5223966bcd52c79cd420f160 --- /dev/null +++ b/misc/entity_state_test_data.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +import sys +import caosdb as db + + +_PASSWORD = "password1A!" + + +def teardown(): + d = db.execute_query("FIND ENTITY WITH ID > 99") + if len(d) > 0: + d.delete(flags={"forceFinalState": "true"}) + + +def setup_users(): + for role in ["publisher", "normal", "external"]: + try: + db.administration._delete_user(name=role+"_user") + except BaseException: + pass + for role in ["publisher", "normal", "external"]: + try: + db.administration._delete_role(name=role) + except BaseException: + pass + for role in ["publisher", "normal", "external"]: + db.administration._insert_role(name=role, description="A test role") + + username = role + "_user" + db.administration._insert_user( + name=username, + password=_PASSWORD, + status="ACTIVE") + db.administration._set_roles(username=username, roles=[role]) + + db.administration._set_permissions( + role="external", permission_rules=[ + db.administration.PermissionRule( + "Grant", "TRANSACTION:RETRIEVE"), + ]) + + db.administration._set_permissions( + role="normal", permission_rules=[ + db.administration.PermissionRule( + "Grant", "TRANSACTION:*"), + db.administration.PermissionRule( + "Grant", "ACM:USER:UPDATE_PASSWORD:?REALM?:?USERNAME?"), + db.administration.PermissionRule( + "Grant", "STATE:TRANSITION:Edit"), + db.administration.PermissionRule( + "Grant", "STATE:TRANSITION:Start Review"), + ]) + + db.administration._set_permissions( + role="publisher", permission_rules=[ + db.administration.PermissionRule( + "Grant", "ACM:USER:UPDATE_PASSWORD:?REALM?:?USERNAME?"), + db.administration.PermissionRule( + "Grant", "TRANSACTION:*"), + db.administration.PermissionRule( + "Grant", "STATE:*"), + ]) + + +def freeze_and_hide(entity): + """ nobody owns this entity and nobody has any permissions""" + entity.acl = db.ACL() + entity.acl.deny(role="?OTHER?", permission="*") + entity.insert() + + +def setup_state_data_model(): + freeze_and_hide(db.RecordType("State")) + freeze_and_hide(db.RecordType("StateModel")) + freeze_and_hide(db.RecordType("Transition")) + freeze_and_hide(db.Property(name="from", datatype="State")) + freeze_and_hide(db.Property(name="to", datatype="State")) + freeze_and_hide(db.Property(name="initial", datatype="State")) + freeze_and_hide(db.Property(name="final", datatype="State")) + freeze_and_hide(db.Property(name="color", datatype=db.TEXT)) + + +def setup_state_model(): + unpublished_acl = db.ACL() + unpublished_acl.grant(role="publisher", permission="*") + unpublished_acl.grant(role="normal", permission="UPDATE:*") + unpublished_acl.grant(role="normal", permission="RETRIEVE:ENTITY") + unpublished_acl = db.State.create_state_acl(unpublished_acl) + + unpublished_state = db.Record( + "Unpublished", + description="Unpublished entries are only visible to the team and may be edited by any team member." + ).add_parent("State").add_property( + "color", + "#5bc0de") + unpublished_state.acl = unpublished_acl + unpublished_state.insert() + + + review_acl = db.ACL() + review_acl.grant(role="publisher", permission="*") + review_acl.grant(role="normal", permission="RETRIEVE:ENTITY") + + review_state = db.Record( + "Under Review", + description="Entries under review are not publicly available yet, but they can only be edited by the members of the publisher group." + ).add_parent("State").add_property( + "color", + "#FFCC33") + review_state.acl = db.State.create_state_acl(review_acl) + review_state.insert() + + + published_acl = db.ACL() + + published_state = db.Record( + "Published", + description="Published entries are publicly available and cannot be edited unless they are unpublished again." + ).add_parent("State").add_property( + "color", + "#333333") + published_state.acl = db.State.create_state_acl(published_acl) + published_state.insert() + + # 1->2 + db.Record( + "Start Review", + description="This transitions denies the permissions to edit an entry for anyone but the members of the publisher group. However, the entry is not yet publicly available." + ).add_parent("Transition").add_property( + "from", + "unpublished").add_property( + "to", + "under review").add_property( + "color", + "#FFCC33").insert() + + # 2->3 + db.Record( + "Publish", + description="Published entries are visible for the public and cannot be changed unless they are unpublished again. Only members of the publisher group can publish or unpublish entries." + ).add_parent("Transition").add_property( + "from", "under review").add_property( + "to", "published").add_property( + "color", + "red").insert() + + # 3->1 + db.Record("Unpublish", description="Unpublish this entry to hide it from the public. Unpublished entries can be edited by any team member.").add_parent( + "Transition").add_property("from", "published").add_property("to", "unpublished").insert() + + # 2->1 + db.Record("Reject", description="Reject the publishing of this entity. Afterwards, the entity is editable for any team member again.").add_parent( + "Transition").add_property("from", "under review").add_property("to", "unpublished").insert() + + # 1->1 + db.Record("Edit").add_parent( + "Transition", + description="Edit this entity. The changes are not publicly available until this entity will have been reviewed and published.").add_property( + "from", + "unpublished").add_property( + "to", + "unpublished").insert() + + db.Record("Publish Life-cycle", description="The publish life-cycle is a quality assurance tool. Database entries can be edited without being publicly available until the changes have been reviewed and explicitely published by an eligible user.").add_parent("StateModel").add_property( + "Transition", + datatype=db.LIST("Transition"), + value=[ + "Edit", + "Start Review", + "Reject", + "Publish", + "Unpublish", + ]).add_property( + "initial", + "Unpublished").add_property( + "final", + "Unpublished").insert() + + +def setup_test_data(): + # any record of this type will have the unpublished state + rt = db.RecordType("TestRT") + rt.state = db.State(model="Publish Life-cycle", name="Unpublished") + rt.insert() + + db.Property("TestProperty", datatype=db.TEXT).insert() + rec = db.Record().add_parent("TestRT") + rec.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + rec.add_property("TestProperty", "TestValue") + rec.insert() + + +if __name__ == "__main__": + for call in sys.argv[1:]: + if call == "setup_test_data": + setup_test_data() + elif call == "setup_state_data_model": + setup_state_data_model() + elif call == "setup_state_model": + setup_state_model() + elif call == "setup_users": + setup_users() + elif call == "teardown": + teardown() + elif call == "all": + setup_users() + setup_state_data_model() + setup_state_model() + setup_test_data() + else: + print("unknown parameter") diff --git a/src/core/css/webcaosdb.css b/src/core/css/webcaosdb.css index edabd9ea671bfac857199f23df3f951bec521ec4..8d4fd1cfe69d64bf631c23eac033baee00873188 100644 --- a/src/core/css/webcaosdb.css +++ b/src/core/css/webcaosdb.css @@ -70,6 +70,15 @@ tbody:not(:hover) tr .caosdb-v-entity-version-hint-cur { display: inline-block; } +.caosdb-v-state-model-label .caosdb-v-state-label { + margin-left: .6em; font-size: 100%; +} + +.caosdb-v-state-label:hover, +.caosdb-v-state-label:active { + filter: brightness(110%); +} + .caosdb-v-bookmark-button, .caosdb-v-bookmark-button:focus, .caosdb-v-bookmark-button:hover { diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index c37dcb1b62b75e5e2078ffb10670288441d2be07..aad96fca3e2d391352c582167975d163f6d1c2ac 100644 --- a/src/core/js/edit_mode.js +++ b/src/core/js/edit_mode.js @@ -1805,6 +1805,26 @@ var edit_mode = new function() { } } + /** + * Programatically start editing an entity. + * + * Throws an error if the action cannot be performed (this or another + * entity is already being edited or the edit_mode is not active). + * + * @param {HTMLElement} entity - the entity which is to be changed. + * @return {HTMLElement} the entity form + */ + this.edit = function (entity) { + if (!this.is_edit_mode()) { + throw Error("edit_mode is not active"); + } + if (!edit_mode.app.can("startEdit")) { + throw Error("edit_mode.app does not allow to start the edit"); + } + edit_mode.app.startEdit(entity); + return edit_mode.app.entity; + } + /** * List of all permissions which indicate that the edit button should be * visible. diff --git a/src/core/js/ext_entity_state.js b/src/core/js/ext_entity_state.js new file mode 100644 index 0000000000000000000000000000000000000000..b4f2e04eb1702636ebe45042e4e3f382efbdd1c4 --- /dev/null +++ b/src/core/js/ext_entity_state.js @@ -0,0 +1,234 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +const ext_entity_state = function ($, logger, edit_mode, update_state, getEntityXML) { + + const entity_state_transition_ready_event = new Event("entity_state.transition.ready"); + + /** + * Return an xml representation of an entity's state + * + * @param {State} state - the entity state + * @return {XMLElement} + */ + const state_to_xml = function (state) { + logger.trace("enter state_to_xml", state); + const name = state.name ? ` name="${state.name}"` : ""; + const model = state.model ? ` model="${state.model}"` : ""; + const id = state.id ? ` id="${state.id}"` : ""; + return str2xml(`<State${name}${model}${id}/>`).firstElementChild; + } + + /** + * Generate a function which replaces the edit_mode.form_to_xml function and + * includes the entity state into the output xml. + * + * Internally, the function calls the original function (proxy pattern). + * + * @pararm {function} original - the original edit_mode.form_to_xml function. + * @return {function} + */ + const create_edit_mode_form_to_xml_function = function (original) { + const result = function (entity_form) { + const entity_xml = original(entity_form); + const state = ext_entity_state.get_entity_state(entity_form); + if (state) { + const state_xml = ext_entity_state.state_to_xml(state); + entity_xml.firstElementChild.appendChild(state_xml); + } + return entity_xml; + } + return result; + } + + /** + * Patch the edit mode to include the entity state (unchanged) into the xml + * when inserting or updating an entity. + */ + const init_edit_mode_patch = function () { + edit_mode.form_to_xml = create_edit_mode_form_to_xml_function( + edit_mode.form_to_xml); + } + + /** + * Return the state of an entity. + * + * @param {HTMLElement} entity - an entity in HTML representation. + * @return {State} + */ + const get_entity_state = function (entity) { + const result = { + "id": entity.getAttribute("data-state-id"), + "name": entity.getAttribute("data-state-name"), + "model": entity.getAttribute("data-state-model"), + }; + if (result.id || (result.name && result.model)) { + return result; + } + return undefined; + } + + /** + * Represents an entity state. + * + * @typedef {Object} State + * @property {string} id - the entity id of the state. + * @property {string} name - the name of the state. + * @property {string} model - the name of the state model. + + /** + * Set a new entity state + * + * @param {HTMLElement} entity - an entity in HTML representation. + * @param {State} state - the new state + */ + const set_entity_state = function (entity, state) { + entity.removeAttribute("data-state-name"); + entity.removeAttribute("data-state-model"); + entity.removeAttribute("data-state-id"); + if (state.id) + entity.setAttribute("data-state-id", state.id); + if (state.name) + entity.setAttribute("data-state-name", state.name); + if (state.model) + entity.setAttribute("data-state-model", state.model); + } + + /** + * Perform or start a state transition. + * + * If the transition is the (special) 'Edit' transition this function + * triggers the edit_mode and opens the entity in the edit_mode form. + * + * Otherwise, the entity is set to the new entity state and then the update + * requests is performed. Apart from that, the entity is left unchanged. + * + * For a reinitialization of relevant clients after the transition, the + * `entity_state_transition_ready_event` is dispatched after the transition + * has been performed. + * + * @param {HTMLElement} entity - entity in HTML representation. + * @param {Transition} transition - the transition + */ + const perform_transition = async function (entity, transition) { + logger.trace("enter perform_transition", entity, transition); + const next_state = ext_entity_state.get_entity_state(entity); + next_state.id = undefined; + next_state.name = transition.to_state; + if (transition.name.toLowerCase() == "edit") { + if(!edit_mode.is_edit_mode()) { + // switch on edit_mode + await edit_mode.toggle_edit_mode(); + } + try { + const entity_form = edit_mode.edit(entity); + ext_entity_state.set_entity_state(entity_form, next_state); + } catch (err) { + logger.warn(err); + } + } else { + const entity_xml = getEntityXML(entity); + const state_xml = ext_entity_state.state_to_xml(next_state); + entity_xml.firstElementChild.appendChild(state_xml); + const response = await ext_entity_state.update_state(entity_xml); + const updated_entity = await transformation.transformEntities(response); + // remove warnings and info messages + $(updated_entity).find(".alert-warning, .alert-info").remove(); + edit_mode.smooth_replace(entity, updated_entity[0]); + updated_entity[0].dispatchEvent(entity_state_transition_ready_event); + } + } + + /** + * Represents a entity state transition + * + * @typedef {object} Transition + * @property {string} name - name of the transition. + * @property {string} to_state - name of the target state of the transition. + + /** + * Return the transition which a button stands for. + * + * @param {HTMLElement} button - a transition button from the state's modal + * popover. + * @return {Transition} + * + */ + const get_transition = function (button) { + return { + "name": button.getAttribute("data-transition-name"), + "to_state": button.getAttribute("data-to-state"), + }; + } + + /** + * @param {HTMLElement} entity + */ + const init_state_transitions = function (entity) { + $(entity) + .find(".caosdb-f-entity-state-transition-button") + .click(function() { + const transition = ext_entity_state.get_transition(this); + ext_entity_state.perform_transition(entity, transition); + $(entity).find(".caosdb-f-entity-state-info").modal("hide"); + }); + + } + + const init = async function () { + logger.info("init ext_entity_state"); + const entities = $(".caosdb-entity-panel"); + + document.body.addEventListener(edit_mode.end_edit.type, (e) => { + // reinitialization after an entity has been changed in the edit mode + init_state_transitions(e.target); + }, true); + + document.body.addEventListener(entity_state_transition_ready_event.type, (e) => { + // reinitialization after a state transition has been performed + init_state_transitions(e.target); + }, true); + + for (let entity of entities) { + // initialize all entities on the page which have a state + init_state_transitions(entity); + } + + init_edit_mode_patch(); + } + + return { + init: init, + state_to_xml: state_to_xml, + get_entity_state: get_entity_state, + perform_transition: perform_transition, + get_transition: get_transition, + update_state: update_state, + } +}($, log.getLogger("ext_entity_state"), edit_mode, update, getEntityXML); + +$(document).ready(function () { + caosdb_modules.register(ext_entity_state); +}); diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 6605a81eca5d81aa146a8b5b9021fb1340982b86..02979a2bea5c057a848e64de9e1ac3c09114177c 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -103,6 +103,11 @@ <xsl:attribute name="data-entity-id"> <xsl:value-of select="@id"/> </xsl:attribute> + <xsl:if test="State"> + <xsl:attribute name="data-state-model"><xsl:value-of select="State/@model"/></xsl:attribute> + <xsl:attribute name="data-state-name"><xsl:value-of select="State/@name"/></xsl:attribute> + <xsl:attribute name="data-state-id"><xsl:value-of select="State/@id"/></xsl:attribute> + </xsl:if> <xsl:apply-templates mode="entity-permissions" select="Permissions"/> <!-- A page-unique ID for this entity --> <xsl:variable name="entityid" select="concat('entity_',generate-id())"/> @@ -145,6 +150,10 @@ </div> <div class="col-sm-4 text-right"> <h5 class="caosdb-v-entity-header-buttons-list"> + <xsl:apply-templates mode="entity-heading-attributes-state" select="State"> + <xsl:with-param name="entityId" select="@id"/> + <xsl:with-param name="hasSuccessor" select="Version/Successor"/> + </xsl:apply-templates> <!-- Button for expanding/collapsing the comments section--> <span class="caosdb-clickable glyphicon glyphicon-comment" data-toggle="collapse" title="Toggle the comments section at the bottom of this entity."> <xsl:attribute name="data-target"> @@ -518,6 +527,86 @@ </li> </ul> </xsl:template> + + <!-- ENTITY STATE --> + <xsl:template mode="entity-heading-attributes-state" match="State"> + <!-- creates a state button in the header of an entity which opens a modal with more information buttons for transitions --> + <xsl:param name="entityId"/> + <xsl:param name="hasSuccessor"/> + <xsl:param name="stateModalId">state-modal-<xsl:value-of select="generate-id()"/></xsl:param> + <span title="State Info"> + <a data-toggle="modal" class="btn btn-link label label-info caosdb-v-state-label"> + <xsl:if test="@color"> + <xsl:attribute name="style">background-color: <xsl:value-of select="@color"/>;</xsl:attribute> + </xsl:if> + <xsl:attribute name="data-target">#<xsl:value-of select="$stateModalId"/></xsl:attribute> + <xsl:value-of select="./@name"/> + </a> + </span> + + <!-- here comes the modal --> + <div class="caosdb-f-entity-state-info modal fade" tabindex="-1" role="dialog"> + <xsl:attribute name="id"><xsl:value-of select="$stateModalId"/></xsl:attribute> + <div class="modal-dialog" role="document"> + <div class="modal-content text-left"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close" title="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title"> + <span class="label label-default caosdb-v-state-model-label" style="padding-right: 0"><xsl:value-of select="@model"/> + <span title="State Info" class="label label-info caosdb-v-state-label"> + <xsl:attribute name="style"> + <xsl:if test="@color"> + background-color: <xsl:value-of select="@color"/>; + </xsl:if> + </xsl:attribute> + <xsl:value-of select="@name"/></span> + </span> + </h4> + <div style="margin-top: 8px"><em><xsl:value-of select="@description"/></em></div> + </div> + <div class="modal-body"> + <xsl:choose> + <xsl:when test="parent::RecordType"> + Every newly inserted Record with type "<xsl:value-of select="parent::RecordType/@name"/>" will initially be in this state. + </xsl:when> + <xsl:when test="$hasSuccessor"> + <p>You are currently viewing an old versions of this entity. <a> + <xsl:attribute name="href"><xsl:value-of select="$entityId"/></xsl:attribute> + Go to the latest version.</a></p> + </xsl:when> + <xsl:otherwise> + <xsl:if test="not(Transition)"> + You cannot perform any transitions. Maybe this is due to lack of permissions. + </xsl:if> + <dl class="dl-horizontal caosdb-f-transition"> + <xsl:for-each select="Transition"> + <dt><button class="btn label label-info caosdb-f-entity-state-transition-button" type="button"> + <xsl:attribute name="data-to-state"><xsl:value-of select="ToState/@name"/></xsl:attribute> + <xsl:attribute name="data-transition-name"><xsl:value-of select="@name"/></xsl:attribute> + <xsl:attribute name="title">Transition to state '<xsl:value-of select="ToState/@name"/>'. <xsl:if test="ToState/@description"><xsl:value-of select="ToState/@description"/></xsl:if></xsl:attribute> + <xsl:attribute name="style"> + font-size: 110%; + <xsl:if test="@color"> + background-color: <xsl:value-of select="@color"/>; + </xsl:if> + </xsl:attribute> + <xsl:value-of select="@name"/></button></dt> + <dd><xsl:value-of select="@description"/></dd> + </xsl:for-each> + </dl> + </xsl:otherwise> + </xsl:choose> + </div> + <div class="modal-footer"> + <a href="?query=FIND Record StateModel WITH name = Model1"> + <xsl:attribute name="href">?query=FIND RECORD StateModel WITH name = "<xsl:value-of select="@model"/>"</xsl:attribute> + View state model</a> + </div> + </div> + </div> + </div> + </xsl:template> + <!--VERSIONING--> <xsl:template match="Version" mode="entity-heading-attributes-version"> <xsl:param name="entityId"/>