issue-pane
Version: 
Solid-compatible Panes: issue editor
595 lines (530 loc) • 23.3 kB
JavaScript
  /*   Issue Tracker Pane
 **
 **  This solid view allows a user to interact with an issue tracker, or individual issue,
 ** to change its state according to an ontology, comment on it, etc.
 **
 */
import { create, login, ns, icons, rdf, tabs, table, utils, widgets } from 'solid-ui'
import { store, authn } from 'solid-logic'
import { board } from './board' // @@ will later be in solid-UI
import { renderIssue, renderIssueCard, getState, exposeOverlay } from './issue'
import { newTrackerButton } from './newTracker'
import { newIssueForm } from './newIssue'
import { csvButton } from './csvButton'
import { trackerSettingsFormText } from './trackerSettingsForm.js'
// import { trackerInstancesFormText } from './trackerInstancesForm.js'
const $rdf = rdf
const kb = store
// const MY_TRACKERS_ICON = UI.icons.iconBase + 'noun_Document_998605.svg'
// const TRACKER_ICON = UI.icons.iconBase + 'noun_list_638112'
// const TASK_ICON = UI.icons.iconBase + 'noun_17020.svg'
const OVERFLOW_STYLE = 'position: fixed; z-index: 100; top: 1.51em; right: 2em; left: 2em; bottom:1.5em; border: 0.1em grey; overflow: scroll;'
export default {
  icon: icons.iconBase + 'noun_122196.svg', // was: js/panes/issue/tbl-bug-22.png
  // noun_list_638112 is a checklist document
  // noun_Document_998605.svg is a stack of twpo checklists
  // noun_97839.svg is a ladybug
  // noun_122196.svg is a clipboard with a check list on it
  // noun_17020.svg is a single check box
  name: 'issue',
  audience: [], // Anyone.   was [ns.solid('PowerUser')]
  // Does the subject deserve an issue pane?
  label: function (subject, _context) {
    const t = kb.findTypeURIs(subject)
    if (
      t['http://www.w3.org/2005/01/wf/flow#Task'] ||
      kb.holds(subject, ns.wf('tracker'))
    ) { return 'issue' } // in case ontology not available
    if (t['http://www.w3.org/2005/01/wf/flow#Tracker']) return 'tracker'
    // Later: Person. For a list of things assigned to them,
    // open bugs on projects they are developer on, etc
    return null // No under other circumstances (while testing at least!)
  },
  mintClass: ns.wf('Tracker'),
  mintNew: async function (context, options) {
    /** Perform updates on more than one document   @@ Move to rdflib!
    */
    async function updateMany (deletions, insertions) {
      const docs = deletions.concat(insertions).map(st => st.why)
      const uniqueDocs = Array.from(new Set(docs))
      const updates = uniqueDocs.map(doc =>
        kb.updater.update(deletions.filter(st => st.why.sameTerm(doc)),
          insertions.filter(st => st.why.sameTerm(doc))))
      return Promise.all(updates)
    }
    const kb = context.session.store
    let stateStore
    if (options.newInstance) {
      stateStore = kb.sym(options.newInstance.doc().uri + '_state.ttl')
    } else {
      options.newInstance = kb.sym(options.newBase + 'index.ttl#this')
      stateStore = kb.sym(options.newBase + 'state.ttl')
    }
    const tracker = options.newInstance
    const appDoc = tracker.doc()
    const me = authn.currentUser()
    if (me) {
      kb.add(tracker, ns.dc('author'), me, appDoc)
    }
    kb.add(tracker, ns.rdf('type'), ns.wf('Tracker'), appDoc)
    kb.add(tracker, ns.dc('created'), new Date(), appDoc)
    // @@ to do --- adk user what sort of tracker they want
    kb.add(tracker, ns.wf('issueClass'), ns.wf('Task'), appDoc) // @@ ask user
    kb.add(tracker, ns.wf('initialState'), ns.wf('Open'), appDoc)
    kb.add(tracker, ns.wf('stateStore'), stateStore, appDoc)
    kb.add(tracker, ns.wf('assigneeClass'), ns.foaf('Person'), appDoc) // @@ set to people in the meeting?
    kb.add(tracker, ns.wf('stateStore'), stateStore, stateStore) // Back Link
    const ins = kb.statementsMatching(undefined, undefined, undefined, appDoc).concat(kb.statementsMatching(undefined, undefined, undefined, stateStore))
    try {
      await updateMany([], ins)
    } catch (err) {
      return widgets.complain(context, 'Error writing tracker configuration: ' + err)
    }
    /*
    try {
      await kb.updater.updateMany([], kb.statementsMatching(undefined, undefined, undefined, stateStore))
    } catch (err) {
      return widgets.complain(context, 'Error writing tracker state file: ' + err)
    }
*/
    const dom = context.dom
    const div = options.div
    const notice = div.appendChild(dom.createElement('div'))
    notice.innerHTML = `<h4>Success</h4>
    <p>Your <a href="${tracker.uri}">new tracker</a> has been made.
    Use the settings tab to configure it.
    </p>
    `
    // console.log('New tracker created ' + tracker)
    // alert('New tracker created')
    return options
  },
  render: function (subject, context) {
    const dom = context.dom
    const paneDiv = dom.createElement('div')
    context.paneDiv = paneDiv
    paneDiv.setAttribute('class', 'issuePane')
    function complain (message) {
      console.warn(message)
      paneDiv.appendChild(widgets.errorMessageBlock(dom, message))
    }
    function complainIfBad (ok, message) {
      if (!ok) {
        complain(message)
      }
    }
    /** Infer subclass from disjoint Union
    **
    ** This is would not be needed if our quey language
    ** allowed is to query ardf Collection membership.
    */
    async function fixSubClasses (kb, tracker) { // 20220228
      async function checkOneSuperclass (klass) {
        const collection = kb.any(klass, ns.owl('disjointUnionOf'), null, doc)
        if (!collection) throw new Error(`Classification ${klass} has no disjointUnionOf`)
        if (!collection.elements) throw new Error(`Classification ${klass} has no array`)
        const needed = new Set(collection.elements.map(x => x.uri))
        const existing = new Set(kb.each(null, ns.rdfs('subClassOf'), klass, doc).map(x => x.uri))
        const superfluous = [...existing].filter(sub => !needed.has(sub))
        const deleteActions = superfluous.map(sub => { return { action: 'delete', st: $rdf.st(kb.sym(sub), ns.rdfs('subClassOf'), klass, doc) } })
        const missing = [...needed].filter(sub => !existing.has(sub))
        const insertActions = missing.map(sub => { return { action: 'insert', st: $rdf.st(kb.sym(sub), ns.rdfs('subClassOf'), klass, doc) } })
        return deleteActions.concat(insertActions)
      }
      const doc = tracker.doc()
      const states = kb.any(tracker, ns.wf('issueClass'))
      const cats = kb.each(tracker, ns.wf('issueCategory')).concat([states])
      let damage = [] // to make totally functionaly  need to deal with map over async.
      for (const klass of cats) {
        damage = damage.concat(await checkOneSuperclass(klass))
      }
      if (damage.length) {
        const insertables = damage.filter(fix => fix.action === 'insert').map(fix => fix.st)
        const deletables = damage.filter(fix => fix.action === 'delete').map(fix => fix.st)
        // alert(`Internal error: s${damage} subclasses inconsistences!`)
        console.log('Damage:', damage)
        if (confirm(`Fix ${damage} inconsistent subclasses in tracker config?`)) {
          await kb.updater.update(deletables, insertables)
        }
      }
    }
    /** /////////////////////////// Board
    */
    function renderBoard (tracker, klass) {
      const states = kb.any(tracker, ns.wf('issueClass'))
      klass = klass || states // default to states
      const doingStates = klass.sameTerm(states)
      // These are states we will show by default: the open issues.
      const stateArray = kb.any(klass, ns.owl('disjointUnionOf'))
      if (!stateArray) {
        return complain(`Configuration error: state ${states} does not have substates`)
      }
      let columnValues = stateArray.elements
      if (doingStates && columnValues.length > 2 // and there are more than two
      ) { // strip out closed states
        columnValues = columnValues.filter(state => kb.holds(state, ns.rdfs('subClassOf'), ns.wf('Open')) || state.sameTerm(ns.wf('Open')))
      }
      async function columnDropHandler (issue, newState) {
        const currentState = getState(issue, klass)
        const tracker = kb.the(issue, ns.wf('tracker'), null, issue.doc())
        const stateStore = kb.any(tracker, ns.wf('stateStore'))
        if (newState.sameTerm(currentState)) {
          // alert('Same state ' + utils.label(currentState)) // @@ remove
          return
        }
        try {
          await kb.updater.update(
            [$rdf.st(issue, ns.rdf('type'), currentState, stateStore)],
            [$rdf.st(issue, ns.rdf('type'), newState, stateStore)])
        } catch (err) {
          widgets.complain(context, 'Unable to change issue state: ' + err)
        }
        boardDiv.refresh() // reorganize board to match the new reality
      }
      function isOpen (issue) {
        const types = kb.findTypeURIs(issue)
        return !!types[ns.wf('Open').uri]
      }
      const options = { columnDropHandler, filter: doingStates ? null : isOpen }
      options.sortBy = ns.dct('created')
      options.sortReverse = true
      function localRenderIssueCard (issue) {
        return renderIssueCard(issue, context)
      }
      // const columnValues = states // @@ optionally selected states would work
      const boardDiv = board(dom, columnValues, localRenderIssueCard, options)
      return boardDiv
    }
    /** ////////////// Table
    */
    function tableRefreshButton (stateStore, tableDiv) {
      const refreshButton = widgets.button(dom, icons.iconBase + 'noun_479395.svg',
        'refresh table', async _event => {
          try {
            await kb.fetcher.load(stateStore, { force: true, clearPreviousData: true })
          } catch (err) {
            alert(err)
            return
          }
          widgets.refreshTree(tableDiv)
        })
      return refreshButton
    }
    function renderTable (tracker) {
      function newOptionalClause () {
        const clause = new $rdf.IndexedFormula()
        query.pat.optional.push(clause)
        return clause
      }
      const states = kb.any(subject, ns.wf('issueClass'))
      const cats = kb.each(tracker, ns.wf('issueCategory')) // zero or more
      const vars = ['issue', 'state', 'created']
      const query = new $rdf.Query(utils.label(subject))
      for (let i = 0; i < cats.length; i++) {
        vars.push('_cat_' + i)
      }
      const v = {} // The RDF variable objects for each variable name
      vars.forEach(function (x) {
        query.vars.push((v[x] = $rdf.variable(x)))
      })
      query.pat.add(v.issue, ns.wf('tracker'), tracker)
      // query.pat.add(v['issue'], ns.dc('title'), v['title'])
      query.pat.add(v.issue, ns.dct('created'), v.created)
      query.pat.add(v.issue, ns.rdf('type'), v.state)
      query.pat.add(v.state, ns.rdfs('subClassOf'), states)
      query.pat.optional = []
      for (let i = 0; i < cats.length; i++) {
        const clause = newOptionalClause()
        clause.add(v.issue, ns.rdf('type'), v['_cat_' + i])
        clause.add(v['_cat_' + i], ns.rdfs('subClassOf'), cats[i])
      }
      const propertyList = kb.any(tracker, ns.wf('propertyList')) // List of extra properties
      if (propertyList) {
        const properties = propertyList.elements
        for (let p = 0; p < properties.length; p++) {
          const prop = properties[p]
          let vname = '_prop_' + p
          if (prop.uri.indexOf('#') >= 0) {
            vname = prop.uri.split('#')[1]
          }
          const oneOpt = newOptionalClause()
          query.vars.push((v[vname] = $rdf.variable(vname)))
          oneOpt.add(v.issue, prop, v[vname])
        }
      }
      const selectedStates = {}
      const possible = kb.each(undefined, ns.rdfs('subClassOf'), states)
      possible.forEach(function (s) {
        if (
          kb.holds(s, ns.rdfs('subClassOf'), ns.wf('Open')) ||
          s.sameTerm(ns.wf('Open'))
        ) {
          selectedStates[s.uri] = true
          // console.log('on '+s.uri); // @@
        }
      })
      function exposeThisOverlay (href) {
        const subject = $rdf.sym(href)
        exposeOverlay(subject, context)
      }
      const tableDiv = table(dom, {
        query: query,
        keyVariable: '?issue', // Charactersic of row
        sortBy: '?created', // By default, sort by date
        sortReverse: true, //   most recent at the top
        hints: {
          '?issue': { linkFunction: exposeThisOverlay, label: 'Title' },
          '?created': { cellFormat: 'shortDate' },
          '?state': { initialSelection: selectedStates, label: 'Status' }
        }
      })
      const stateStore = kb.any(subject, ns.wf('stateStore'))
      tableDiv.appendChild(tableRefreshButton(stateStore, tableDiv))
      return tableDiv
    }
    // Allow user to create new things within the folder
    function renderCreationControl (refreshTarget) {
      const creationDiv = dom.createElement('div')
      const me = authn.currentUser()
      const creationContext = {
        // folder: subject,
        div: creationDiv,
        dom: dom,
        noun: 'tracker',
        statusArea: creationDiv,
        me: me,
        refreshTarget: refreshTarget
      }
      const issuePane = context.session.paneRegistry.byName('issue')
      const relevantPanes = [issuePane]
      create.newThingUI(creationContext, context, relevantPanes) // Have to pass panes down  newUI
      return creationDiv
    }
    function renderInstances (theClass) {
      const instancesDiv = dom.createElement('div')
      const context = { dom, div: instancesDiv, noun: 'tracker' }
      login.registrationList(context, { public: true, private: true, type: theClass }).then(_context2 => {
        instancesDiv.appendChild(renderCreationControl(instancesDiv))
        /* // keep this code in case we need a form
        const InstancesForm = ns.wf('TrackerInstancesForm')
        const text = trackerInstancesFormText
        $rdf.parse(text, kb, InstancesForm.doc().uri, 'text/turtle')
        widgets.appendForm(dom, instancesDiv, {}, tracker, InstancesForm,
          tracker.doc(), complainIfBad)
        */
      })
      return instancesDiv
    }
    function renderSettings (tracker) {
      const settingsDiv = dom.createElement('div')
      settingsDiv.appendChild(csvButton(dom, tracker)) // Button to copy the tracker as a CSV file
      const states = kb.any(tracker, ns.wf('issueClass'))
      const views = [tableView, states] // Possible default views
        .concat(kb.each(tracker, ns.wf('issueCategory')))
      const box = settingsDiv.appendChild(dom.createElement('div'))
      const lhs = widgets.renderNameValuePair(dom, kb, box, null, 'Default view') // @@ use a predicate?
      lhs.appendChild(widgets.makeSelectForOptions(dom, kb, tracker,
        ns.wf('defaultView'),
        views, {}, tracker.doc()))
      // A registration control allows the to record this tracker in their type index
      const context = { dom, div: settingsDiv, noun: 'tracker' }
      login.registrationControl(context, tracker, ns.wf('Tracker')).then(_context2 => {
        const settingsForm = ns.wf('TrackerSettingsForm')
        const text = trackerSettingsFormText
        $rdf.parse(text, kb, settingsForm.doc().uri, 'text/turtle')
        widgets.appendForm(dom, settingsDiv, {}, tracker, settingsForm,
          tracker.doc(), complainIfBad)
      })
      return settingsDiv
    }
    function renderTabsTableAndBoard () {
      function renderMain (ele, object) {
        ele.innerHTML = '' // Clear out "loading message"
        if (object.sameTerm(boardView)) {
          ele.appendChild(renderBoard(tracker))
        } else if (object.sameTerm(tableView)) {
          ele.appendChild(renderTable(tracker))
        } else if (object.sameTerm(settingsView)) {
          ele.appendChild(renderSettings(tracker))
        } else if (object.sameTerm(instancesView)) {
          ele.appendChild(renderInstances(ns.wf('Tracker')))
        } else if ((kb.holds(tracker, ns.wf('issueCategory'), object)) ||
                   (kb.holds(tracker, ns.wf('issueClass'), object))) {
          ele.appendChild(renderBoard(tracker, object))
        } else {
          throw new Error('Unexpected tab type: ' + object)
        }
      }
      const states = kb.any(tracker, ns.wf('issueClass'))
      const items = [instancesView, tableView, states]
        .concat(kb.each(tracker, ns.wf('issueCategory')))
      items.push(settingsView)
      const selectedTab = kb.any(tracker, ns.wf('defaultView'), null, tracker.doc()) || tableView
      const options = { renderMain, items, selectedTab }
      // Add stuff to the ontologies which we believe but they don't say
      const doc = instancesView.doc()
      kb.add(instancesView, ns.rdfs('label'), 'My Trackers', doc) // @@ squatting on wf ns
      kb.add(settingsView, ns.rdfs('label'), 'Settings', doc) // @@ squatting on wf ns
      kb.add(states, ns.rdfs('label'), 'By State', doc) // @@ squatting on wf ns
      const myTabs = tabs.tabWidget(options)
      return myTabs
    }
    async function renderSingleIssue () {
      tracker = kb.any(subject, ns.wf('tracker'))
      if (!tracker) throw new Error('This issue ' + subject + 'has no tracker')
      // Much data is in the tracker instance, so wait for the data from it
      try {
        const _xhrs = await context.session.store.fetcher.load(tracker.doc())
      } catch (err) {
        const msg = 'Failed to load tracker config ' + tracker.doc() + ': ' + err
        return complain(msg)
      }
      const stateStore = kb.any(tracker, ns.wf('stateStore'))
      if (!stateStore) {
        return complain('Tracker has no state store: ' + tracker)
      }
      try {
        await context.session.store.fetcher.load(subject)
      } catch (err) {
        return complain('Failed to load issue state ' + stateStore + ': ' + err)
      }
      paneDiv.appendChild(renderIssue(subject, context))
      updater.addDownstreamChangeListener(stateStore, function () {
        widgets.refreshTree(paneDiv)
      }) // Live update
    }
    async function renderTracker () {
      function showNewIssue (issue) {
        widgets.refreshTree(paneDiv)
        exposeOverlay(issue, context)
        newIssueButton.disabled = false // https://stackoverflow.com/questions/41176582/enable-disable-a-button-in-pure-javascript
      }
      tracker = subject
      try {
        await fixSubClasses(kb, tracker)
      } catch (err) {
        console.log('@@@ Error fixing subclasses in config: ' + err)
      }
      const states = kb.any(subject, ns.wf('issueClass'))
      if (!states) throw new Error('This tracker has no issueClass')
      const stateStore = kb.any(subject, ns.wf('stateStore'))
      if (!stateStore) throw new Error('This tracker has no stateStore')
      // const me = await authn.currentUser()
      const h = dom.createElement('h2')
      h.setAttribute('style', 'font-size: 150%')
      paneDiv.appendChild(h)
      const classLabel = utils.label(states)
      h.appendChild(dom.createTextNode(classLabel + ' list')) // Use class label @@I18n
      // New Issue button
      const newIssueButton = dom.createElement('button')
      const container = dom.createElement('div')
      newIssueButton.setAttribute('type', 'button')
      newIssueButton.setAttribute('style', 'padding: 0.3em; font-size: 100%; margin: 0.5em;')
      container.appendChild(newIssueButton)
      paneDiv.appendChild(container)
      const img = dom.createElement('img')
      img.setAttribute('src', icons.iconBase + 'noun_19460_green.svg')
      img.setAttribute('style', 'width: 1em; height: 1em; margin: 0.2em;')
      newIssueButton.appendChild(img)
      const span = dom.createElement('span')
      span.innerHTML = 'New ' + classLabel
      newIssueButton.appendChild(span)
      newIssueButton.addEventListener(
        'click',
        function (_event) {
          newIssueButton.disabled = true
          container.appendChild(newIssueForm(dom, kb, tracker, null, showNewIssue))
        },
        false
      )
      // Table of issues - when we have the main issue list
      // We also need the ontology loaded
      //
      context.session.store.fetcher
        .load([stateStore])
        .then(function (_xhrs) {
          const tableDiv = renderTabsTableAndBoard(tracker)
          // const tableDiv = renderTable(tracker) // was
          paneDiv.appendChild(tableDiv)
          if (tableDiv.refresh) {
            // Refresh function
          } else {
            console.log('No table refresh function?!')
          }
          paneDiv.appendChild(newTrackerButton(subject, context))
          updater.addDownstreamChangeListener(stateStore, tableDiv.refresh) // Live update
        })
        .catch(function (err) {
          return console.log('Cannot load state store: ' + err)
        })
      // end of Tracker instance
    } // render tracker
    /* Render tabs with both views
    */
    const boardView = ns.wf('BoardView')
    const tableView = ns.wf('TableView')
    const settingsView = ns.wf('SettingsView')
    const instancesView = ns.wf('InstancesView')
    const updater = kb.updater
    const t = kb.findTypeURIs(subject)
    let tracker
    // Whatever we are rendering, lets load the ontology
    const flowOntology = ns.wf('').doc()
    if (!kb.holds(undefined, undefined, undefined, flowOntology)) {
      // If not loaded already
      $rdf.parse(require('./wf.js'), kb, flowOntology.uri, 'text/turtle') // Load ontology directly
    }
    const userInterfaceOntology = ns.ui('').doc()
    if (!kb.holds(undefined, undefined, undefined, userInterfaceOntology)) {
      // If not loaded already
      $rdf.parse(require('./ui.js'), kb, userInterfaceOntology.uri, 'text/turtle') // Load ontology directly
    }
    // Render a single issue
    if (
      t['http://www.w3.org/2005/01/wf/flow#Task'] ||
      kb.holds(subject, ns.wf('tracker'))
    ) {
      renderSingleIssue().then(() => console.log('Single issue rendered'))
    } else if (t['http://www.w3.org/2005/01/wf/flow#Tracker']) {
      //          Render a Tracker instance
      renderTracker().then(() => console.log('Tracker rendered'))
    } else {
      console.log(
        'Error: Issue pane: No evidence that ' +
          subject +
          ' is either a bug or a tracker.'
      )
    }
    let loginOutButton
    const overlay = paneDiv.appendChild(dom.createElement('div'))
    context.overlay = overlay
    overlay.style = OVERFLOW_STYLE
    overlay.style.visibility = 'hidden'
    authn.checkUser().then(webId => {
      if (webId) {
        console.log('Web ID set already: ' + webId)
        context.me = webId
        // @@ enable things
        return
      }
      loginOutButton = login.loginStatusBox(dom, webIdUri => {
        authn.log
        if (webIdUri) {
          context.me = kb.sym(webIdUri)
          console.log('Web ID set from login button: ' + webIdUri)
          paneDiv.removeChild(loginOutButton)
          // enable things
        } else {
          context.me = null
        }
      })
      loginOutButton.setAttribute('style', 'margin: 0.5em 1em;')
      paneDiv.appendChild(loginOutButton)
      if (!context.statusArea) {
        context.statusArea = paneDiv.appendChild(dom.createElement('div'))
      }
    })
    return paneDiv
  }
}
// ends