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