ucfopen / Obojobo

Next generation course content for your LMS. Easy for beginners, but powerful enough for researchers.
https://ucfopen.github.io/Obojobo-Docs/
GNU Affero General Public License v3.0
70 stars 34 forks source link

Variables and experimental features. #2116

Open FrenjaminBanklin opened 1 year ago

FrenjaminBanklin commented 1 year ago

THIS PULL REQUEST HAS MIGRATIONS.

Closes #1147. Closes #1814. Significant progress towards #554.

Adds variable creation and substitution and experimental feature flags.

Adds a 'state' column to the 'visits' table to store the variables generated for a visit rather than regenerating them every time.

FEATURE FLAGS

This PR adds the ability to enable experimental features that are not ready for general user consumption but could potentially be used by power users. Currently this is handled at the browser level using localStorage, which can be set manually or simplified using a globally available API. For example, to enable the variable creation UI in the editor, run the following in the console:

window.obojobo.flags.set('experimental.variables', true)

VARIABLES

Variables can be defined on any node or page in a module or at the top level of the module itself. Variables should be automatically available to any node in the hierarchy under the location at which the variable is defined, but can also be referred to by variables not in the vertical hierarchy by using specific notation. For example, suppose the module has defined variables $a and $b. A Text node on any page should be able to use those variables automatically:

A text node's ordinary contents can have variables included using notation such as {{$a}} and {{$b}}.

Or, for example, a text node with an ID of 'some-text-node' could define a variable $c. Another text node - a sibling, or a text node on another page - can also use that variable:

A node on another page has a variable {{$some-text-node:c}}.

Things that still need doing:


PROBLEMS

FrenjaminBanklin commented 12 months ago

This is just about done and working, but there are a couple of potentially serious problems that we probably need to address before we can ship this.

These problems only really surface when using the {{$varname}} shorthand. Substitution appears to be working properly in all cases when using the {{$ownerid:varname}} format, but I don't think we can reliably expect content authors to be mindful of this and it kind of goes against the whole purpose of being able to define variables in multiple places using the same name.

Paging @zachberry for any ideas for approaching the things listed in 'Problems' in the PR description above. Currently it doesn't look like there is any way of circumventing the way Obojobo handles questions in assessment attempts and during assessment review in order to make the questions' full node ancestor hierarchies available to the variable util. I'm considering trying to find some way of attaching each question's full ancestor tree on the server side and then somehow using that information on the viewer side, but even in the best case scenario the solution is going to be extremely hacky.

zachberry commented 12 months ago

I probably need to re-familiarize myself a bit more, but some initial top-of-my-head thoughts:

...
   Page (id=p1, variables: studentName=chooseOne('Dick', 'Jane'))
      TextChunk (id=tc1)
         Text (id=t1)
            textGroup: "Imagine a student named {{ studentName }}. When {{ p1:studentName }} drives 10 miles..."

The server could return the following when requesting a document (I'm not tied to this exact structure, just trying to convey the idea):

{
  root: { //...the whole obo document...// },
  variables: [
    {
      name: "studentName",
      value: "Jane",
      owner: "p1",
      refs: ["t1"]
    }
  ]
}

So the server comes back with a variable value it chose (Jane), where the variable is defined (the "owner", which is "p1", the Page) and finally anywhere the variable is used (the "refs", which is "t1", the TextChunk that uses it).

(You would ideally want to further process the variables data client-side to make it more useful to quickly look up - maybe make a data structure like this:)

const variableValuesByOboNodeId = {
  p1: {
    studentName: "Jane"
  },
  t1: {
    studentName: "Jane"
  }
}

That way when the parser/viewer comes to {{ studentName }} or {{ p1:studentName}} you would have enough information to find what its value should be.

If this was done for everything in the document then you would be fine if you're missing part of the node structure, such as in Assessment, because that variables response gives you everything you need to connect the dots.

Thoughts? I might be missing something obvious since it's been a minute since I looked at this stuff.

FrenjaminBanklin commented 12 months ago

My knee-jerk reaction to performing variable substitutions server-side but just for assessments is that we may as well just perform substitutions server-side for the whole document. There was a lot of code that seemed like it wasn't being used that I just got rid of, I'm not sure if any of it would have been supportive of this approach or if it was just left over from some unrelated experimentation. But then that seems like it would require some pretty significant rearchitecting of not just the way variables work but the way the whole viewer backend works. I'm also not sure if moving substitutions out of the client side completely would make it harder to use variables in nav links - or since the substitution would be done already if the nav links would just have the correct text to begin with.

I can look into limiting the server-side pre-baked substitutions to just everything downstream of the Assessment. I'd taken a really tentative step in this direction before but aborted because I couldn't see any obvious convenience tools for traversing a draft tree in reverse (there's a getChildNodeById method, but not a getParentNode method, for example - so that's something we'll probably want to add to make our lives easier) or for easily checking a node's text for the variable string once it's objectified (though admittedly I didn't experiment much with this). It'll be a bit of a hack to have substitution happening two different ways in two different places, but it could just be a 'this works for now, standardize on one later' kind of deal.

FrenjaminBanklin commented 12 months ago

The solution I ended up coming up with was a bit of a hybrid. When an attempt is started, each chosen question will have all of its descendent nodes checked for variables. After all of the variables used anywhere in the question have been aggregated, for each variable all of the question's ancestor nodes will be checked until the closest one that owns a variable of the same name. Once a match is found, a reference is stored as part of the attempt.state.chosen payload in a new varRef key so that it can be referenced when resuming or reviewing an attempt.

Luckily this only has to happen the one time, and then whenever attempt.state.chosen is used to pull all of the questions out of a draft, a separate step can recurse through all of the child nodes for each chosen question if a varRef exists for that question, and whenever a string matching the given variable is found it will just be replaced with the long form syntax.

So if a question contains any text nodes with a {{$qb}}, and that question's parent QuestionBank with an ID of 'qb-id' is the nearest node with a 'qb' variable defined, then before that question reaches the client for rendering all occurrences of {{$qb}} within the question will be changed to {{$qb-id:qb}}. At that point the existing tools on the client side should handle it.

It turns out that using Maps to manage the nodes in the draft tree makes it pretty difficult to determine the parent of any given node. At least, it seems that way to me. I'm willing to believe there's some convenience or cleverness that I could have used instead of my 'turn it into an array and just traverse it multiple times to find everything' approach, but what I came up with seems to be working. I've also adjusted the constructor code for the draft tree in currentDocument so that whenever a node detects that it has children, it adds a parentId property to those children so that it's easier to traverse the tree in both directions.