Skip to content

High-level method for state transitions #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
timkindberg opened this issue Feb 14, 2013 · 37 comments
Closed

High-level method for state transitions #15

timkindberg opened this issue Feb 14, 2013 · 37 comments
Assignees

Comments

@timkindberg
Copy link
Contributor

transitionTo() is a fairly low-level method in that it expects a "fully qualified" state and all parameters to be passed in. I want to add a higher-level method on top of it for everyday use where you can do stuff like

  • Keep the values of existing parameters and only specify what changed
  • Have some short-hand syntax for identifying parent or sibling states, maybe something like
  • This syntax needs to work well from JS code and from within Angular expressions in a template -- from code having a state name (or short-hand), and a hash of parameters as two separate arguments seems fine, but I'm not sure how that would look within a directive (see next bullet point); I suppose the parameters could be a separate named attribute of the directive.
<!-- Maybe '@' could be short-hand for ($state.current.name + '.') ? -->
<button ng-click="$state.go('@edit')>Edit</button>
@timkindberg
Copy link
Contributor Author

I like $state.go.

  • Would go be able to accept an object-based state parameter? e.g. $state.go(contacts)
  • For navigating to child I like '.' instead of '@' because it matches our state naming convention where as @ is being used for multiple views, so like this $state.go($state.current.parent)
  • For navigating to a parent or root I would think we could do something like $state.go($state.current.parent) (or jsut $state.parent) and $state.go($state.root)
  • For navigating to a sibling, who knows maybe $state.go('..siblingName'), two dots. Or '^name' or '>name'. I thought of '..' because its like navigating folders "../foldername" would get you to a sibling folder, but I didn't want to introduce the slash for fear it would start to feel like urls.

@nateabele
Copy link
Contributor

As stated in #14, the traditional way to build a state machine is by defining events that specify valid state transitions. Using proper definitions, it is possible to determine which transitions are valid for each state, and preventing the state machine from reaching an unstable or unexpected state. Here's the canonical car example:

// Assume the following available states: "parked", "stalled", "idling", "firstGear", "secondGear"

.event("park", {
    from: ["idiling", "firstGear"],
    to: "parked"
})
.event("start" [
    { from: "stalled", to: "stalled" },
    { from: "parked", to: "idling" }
])
.event("idle" {
    from: "firstGear",
    to: "idling"
})
.event("shiftUp", [
    { from: "idiling", to: "firstGear" },
    { from: "firstGear", to: "secondGear" }
])
.event("shiftDown", [
    { from: "firstGear", to: "idling" },
    { from: "secondGear", to: "firstGear" }
])
.event("crash", {
    from: "*",
    to: "stalled"
]);

The events then become methods available on $state objects. Then, rather than there being any ambiguity, or possibility that the machine could transition into an unexpected state, the possibilities are clear:

// parked:
$state.start() // => "idiling"

// idling:
$state.shiftUp() // => "firstGear"

// firstGear:
$state.crash() // => "stalled"

// stalled:
$state.shiftUp() // => Error: Invalid state transition

The syntax here is just an example, the important part is the structure and semantics.

Another great feature of proper state machine events is that it makes wizard-like prev()/next() setups trivial. You just keep calling next() on each state to move to the next one.

This also solves the 'can we get there from here?' problems with transitionTo() (which would still exist in a generic 'event' like go()): you just need to do a reverse lookup to see if a valid event exists.

@timkindberg
Copy link
Contributor Author

I think I love this! Should this a separate issue? Definitely needs to be explored!

@nateabele
Copy link
Contributor

Having a separate issue is up to you. I just thought it made sense to put this here, since the events you define would be your high-level transition methods (and we can bundle parameter-passing up in them, too).

The other cool thing here, obviously, is that it takes the complication of explicit state references out of your templates.

@timkindberg
Copy link
Contributor Author

Yeah ok, keep it here then. So would we then also do the hooks too like this?:

.event("park", {
    from: ["idiling", "firstGear"],
    to: "parked",
    before: fn(){},
    between: fn(){},
    after: fn(){}
})

@nateabele
Copy link
Contributor

That could work, but you'd be more limited in how you apply them (for example, some hooks might apply over several events, so that could be pretty constraining). The syntax would at least need to be different though, because the second parameter needs to be able to be an array or an object hash.

@gregwebs
Copy link

This is not a good example for the project: it is a traditional FSM example, whereras we are dealing with application state. I found in my production application that there was no need to define transitions between states: instead the enter/exit callback hierarchy handled everything perfectly with the exception of first restricting based on login. It is probably important to this that StateTree supports concurrent sub states, which are much more difficult to figure out how to map to routes.

Defining transition events should be most useful for placing restrictions, so you might consider going back to #14 to discuss this. Either way you should make sure to use a real-world application routing example.

@timkindberg
Copy link
Contributor Author

@gregwebs are you saying the before, between and after are not good, or the $state.event idea? I'm not quite sure how we'd use the transition hooks, but I do see how $state.event could be very helpful for high level navigation and actions (as @nateabele pointed out it could be used for prev/next actions as one example). We've heard from at least @jeme on actions as well.

@gregwebs
Copy link

I found the entire concept of event transitions ($state.event in this case) to be unnecessary. I just go to the state and the enter/exit callbacks handle things.

I don't think I understand the next/prev suggestion. That implies a linear ordering of states, but even in a traditional FSM one jumps around based on conditions & events. It seems that under an event setup a user would have to make an extra effort to define a chain of states that work based just on a next() invocation.

I think it could only be done automatically in our hierarchical setups as a way to move up & down from parent to child states when there is only one child or to move across sibling child states of a parent.

@nateabele
Copy link
Contributor

I found the entire concept of event transitions ($state.event in this case) to be unnecessary. I just go to the state and the enter/exit callbacks handle things.

If you had a couple of examples handy, that might help with a point/counterpoint. I'm not committed to one way or the other right now, but in my experience having events be a separate thing helps to keep hook points DRY.

I don't think I understand the next/prev suggestion. That implies a linear ordering of states, but even in a traditional FSM one jumps around based on conditions & events.

This was just one off-the-cuff example explaining polymorphism of events and transitions, i.e. that the same event can be called in different contexts, with relevant contextual effects. In a linear wizard-like example, for one thing, it's possible to change your event definitions without having to poke through your UI bindings. Hope that makes sense.

@timkindberg
Copy link
Contributor Author

I don't think I understand the next/prev suggestion. That implies a linear ordering of states, but even in a traditional FSM one jumps around based on conditions & events.

Yeah it wouldn't be a permanent API fixture, the next/prev events would be added by the user as needed. Similar to how the shift up/shift down events work in @nateabele's car example.

@ksperling
Copy link
Contributor

I tend to agree with @gregwebs on this -- named transitions with actions associated with the transitions are obviously the bread and butter of traditional state machines, but I think those are quite different from our use case here.

I think it would really help to have some real world use cases of what sort of things you'd actually want to do in those "when going from A to B, do this" hooks. The provisioning of the UI templates / controllers is taken care of separately already.

I can't think of much myself -- even onEnter I don't see being used particularly often, and cases where i'd then additionally want to do different things based on what the previous state was seem rarer still. Happy to be convinced otherwise though.

We're still talking about web application UI's here... The idea that the same "page" in the app would behave radically different depending on how you got there seems to imply a rather odd user experience (unless you're building some game where each state represents a "room" or something like that...)

@timkindberg
Copy link
Contributor Author

Ok @ksperling. It is an interesting idea and I think users would think it was really cool, but you are right that maybe it's not needed in a conventional website state management tool. If @gregwebs is saying we don't need it, then we probably don't (I mean he DID create a state tree library). So let's table that for now, @nateabele can write it up as a separate issue at this point and label as "review later" if he still feels strongly.

Back to the main issue then... high level methods. You really have to now go back up to my first comment in order to get my initial feedback. https://github.com/angular-ui/router/issues/15#issuecomment-13609283

@ludinov
Copy link

ludinov commented Feb 16, 2013

Some common scenarios of using state transition events:

  • "ui loading state" while waiting for resolver (e.g. long ajax request)
  • redirect to parent (or another state) state on rejecting transition
  • For example direct link /blogs/3/posts/4/comments/5/autor mapped to 4 nested states and sequence of 4 ajax requests, and now until we resolve the last one, user will see just blank screen, instead of step by step ui transformation progress. May be it's possible to define "safe" state to enter in the middle of the chain.
  • For example I want to prevent user to leave the view (state) without confirmation, if he change some form input fields, but didn't save the form. So I need to have ability to prevent transition on exit state, (return false in onExit or rejected promise or throw exeption) and need to have access to the scope in handler or share something with controller. (e.g. with $state or another injectable)

I think transition should be chain of promises amendable for each step.

e.g. : app.user.details.edit => app.admin.users.list

chain:

app.user.details.edit : leave
app.user.details      : leave
app.user              : leave
app                   : (maybe some 'change substate' event ?)
app.admin             : enter
app.admin.users       : enter
app.admin.users.list  : enter

just "pseudoapi":

onTransition(from, to, transitionHandler, fallbackHandler)

onTransition(
    '^',          // '^' = parent,
    'app.admin',  // '*' = any, '-' = sibling
    function(..injects..) {
        return auth(); // promise
    },
    function(..injects..) { // executed after main transition rejection logic
        $state.goTo('app.login');
    }
)

But this type of api doesn't solve some problems:

  1. should we move to (render) intermediate state before full chain resolution ?
  2. how can we control ui somehow inside handlers to show some progress to the user during transition?
  3. how to communicate with state controllers scopes inside of handlers (may be with some shared injectable or with params..) ?

Btw $state.go('admin') could return promise, that we can control explicitly:

...onClick = function() { 
    $q.when()
        .then(ui.overlay.show)
        .then(function() { $state.go('admin.dashboard') } )
        .then(null, function() { $state.go('login') })
        .then(ui.overlay.hide, ui.overlay.hide)

or $state.go('admin') could return promise maker function to beautify syntax:

    $q.when()
        .then(ui.overlay.show)
        .then($state.go('admin.dashboard'))
        .then(null, $state.go('login'))
        .then(ui.overlay.hide, ui.overlay.hide)

but where to place this logic ? Definitely not in controller, but $state.go inside state transition event handler looks also weird...

@timkindberg
Copy link
Contributor Author

I think the loading indicator could just be a loaded property that is true once all views and dependencies have been loaded and resolved. The you can use the property in you templates to show some indicator if loaded while false. I think this should be a separate feature request because its SO common.

We may be able to do state rejection in other ways see the issue related to that topic. Lets bring redirects into that discussion.

Your 3rd bullet: interesting idea. Maybe add a needsToLoad property to the view object to specify which views aren't vital to the page to display.

The chain you specify us already happening behind the scenes but @ksperling would have to confirm that each ancestor dispatches its own event. There is another issue talking about dispatching. Perhaps we need more events to be dispatched; one per every exit and enter.

No comment on the other stuff for now.

@jeme
Copy link
Contributor

jeme commented Feb 18, 2013

The provisioning of the UI templates / controllers is taken care of separately already.

That might actually turn out to be a rather annoying limitation. I choose to have a "view service" or "view provider" if you will... which basically allows to:

$view.set('main', '/tpl/user/dashboard.tpl', DashboardController);

Controller being optional, but that means that views can be exchanged on the fly just as is possible with ng-include linked to a scope property.

(The way it works actually gives the incremental rendering of views as requested above, not something I particular aimed for though)

@ksperling
Copy link
Contributor

@jeme One problem I have with the 'step by step' idea is that the intermediate steps are not necessarily valid states in the state machine sense. In the sample app, going from "about" to "contacts.detail" would pass through "contacts", but that is an abstract state that cannot be meaningfully navigated to by itself (the whole set of exit/enter callbacks is still invoked though, but only after all the asynchronous resolves have completed). There are multiple useful aspects to doing the state transition atomically:

  • It's atomic. Code that looks at $state.current can always be sure that it is looking at a valid state of the UI, not some intermediate.
  • It's atomic. Either all templates and dependencies resolve successfully and that state transition happens, but if something fails (or the transition gets rejected, however we end up doing that exactly) the state remains exactly as it was, not in some undefined intermediate state.
  • All the asynchronous work of resolving templates and dependencies happens in parallel, and therefore in general takes less time.
  • If a child state overrides a view also define by a parent state, we don't end up first loading the parent view and then discarding it again shortly after.

I guess the incremental loading may be desirable in some cases, even though I'm not sure this really needs to be done at the state machine level -- if its the exception rather than the rule in your application you can always do some additional async work in the controller after the view has been rendered. It might be feasible to implement your 'safe state' idea -- even though I'd probably call them 'eager' rather than safe -- it seems like error handling on the application level would become rather tricky though. I think the simplicity and robustness of atomic transitions probably makes them the right choice in 98% of cases, so to me incremental transitions would be in the "investigate for v2" category at the moment.

The idea of a separately usable $view service is interesting; the interface between ui-view and $stateProvider is currently $state.$current.locals, which is a map from view names to views (template/controller/resolved deps), so rather than just setting that directly we could think about exposing that touch point as a service.

We have to think carefully about how this would work in terms of asynchronous loading -- $stateProvider only "pushes" a view into this global data structure when everything is fully resolved, wheras your $view.set(...) example still requires things to be loaded before the view can actually be 'set', so maybe should be called $view.load(...) instead. We'd need to be careful to make sure that overlapping calls to $view.load() have a sane outcome (last call wins i guess), and how this would interact with concurrent synchronous $view.set() calls as they would be done by $stateProvider. The behaviour when you manually update a view that $stateProvider also manages could also be problematic.

Another question is how clearing views would work -- $stateProvider currently simply pushes all views assigned by the current state in one atomic operation (by replacing $state.$current), which implicitly also clears any views not assigned in the current state. If views can be set via $view directly, it seems on a transition A -> B $stateProvider would need to work out and explicitly clear any views assigned by A that aren't assigned in B.

I can think of some vague use cases for $view, e.g. having some sort of contextual assistant view in a side bar that is set dynamically based on user input or some other conditions. Do you have any concrete use cases for this feature in your apps? Maybe we should open a separate ticket for $view and work out how it would work in detail, and see if there are enough use cases to justify the added complexity (which hopefully won't be so big).

One thing I do quite like about $view would be that it allows people who don't like $state for some reason to implement their own state machine while reusing $urlRouter and $view, but this is a somewhat niche feature as we should aim for $stateProvider to cater to the majority of use cases.

@jeme
Copy link
Contributor

jeme commented Feb 23, 2013

@ksperling It wasn't me who brought the step-by-step up, it was mostly a desire i gathered from @ludinov's response...

One thing I do quite like about $view would be that it allows people who don't like $state for some reason to implement their own state machine while reusing $urlRouter and $view, but this is a somewhat niche feature as we should aim for $stateProvider to cater to the majority of use cases.

Thats not the only thing it does to me, it also separates responsibilities/concern, and even if it is not for the outside to use, that would certainly be beneficial from an inside perspective as well...

Adding the necessary details to support what you refer to as atomic would properly also be possible, since all my current state transitions is by very definition atomic, I don't have the issue you mention, the reason I get incremental reload on certain occasions is because I in my usage of the implementation perform multiple transitions.

@nateabele
Copy link
Contributor

@ksperling I was gonna start working on parameter inheritance for $state.href() / ui-sref. Maybe that could be the basis of a short-hand wrapper method for transitionTo(). Would that step on anything you're working on currently?

@ksperling
Copy link
Contributor

Go ahead, I'm still working on the 'resolve' stuff. Maybe we should just include it in transitionTo directly at this point -- the idea of a wrapper was that somebody could decorate $state to modify it's behaviour, and still take advantage of sugar layed on via wrapper functions, but I don't think that sort of use is common (or happening at all?) at the moment.

@nateabele
Copy link
Contributor

@ksperling Yeah, not really. Generally, I doubt states will be nested deeply enough to make the idea of i.e. $state.go("..") really matter. Btw, what do you think about changing the 3rd param of transitionTo() an options hash? So, for example $state.transitionTo("foo", params, { location: true, inherit: false })?

@ksperling
Copy link
Contributor

Yes, third parameters should become an options hash, which then internally becomes the "transition" object that gets passed to onEnter/onExit etc instead of the current separate to/from/toParams/fromParams etc.

@ksperling
Copy link
Contributor

Being able to say ".." is less about saving typing and more about decoupling states from their parent where that makes sense.

@nateabele
Copy link
Contributor

Okay, sounds good. I'll get started on it, but you'll have to show me how you want it. Re: "..", that makes sense. Should that be baked into transitionTo() as well?

@ksperling
Copy link
Contributor

Maybe add an optional 'base' parameter to findState() so that if the first parameter is given as a name and base is specified it resolves the shortcuts.

We have to think about what we want the syntax to be... I think we should avoid "/", because it would make stuff look too much like URLs, which will be quite confusing because the state nesting is often similar to but different from the URLs. Maybe ".." for parent "..." for grandparent, and "&" for current state? All being treated as prefixes, so you can say "&child", "..sibling", "...uncle"

It's also worth thinking about if we want these to effectively be string operations, or how we handle the case where a state specifies it's parent explicitly rather than via the dot syntax. I think it makes sense to resolve the up references by walking the state.parent tree, and then treat a suffix (if present) by appending "." + suffix to the name of that state.

@ksperling
Copy link
Contributor

(I think we should allow any number of "." for up navigation, even though in practice people probably won't use more than 3 or 4 at most)

@ksperling
Copy link
Contributor

As an aside, it seems to me that while the "parent.child" way of naming states and implicitly setting the parent is nice when there is tight coupling between the child and the parent anyway, but that the explicit parent setting is nicer when they are essentially independent, as it makes it easy to move the child to a different place in the hierarchy / navigation structure of the app without having to rename any states.

The ability to move states around by having them only loosely coupled to their parent is also something that needs to be considered in any changes to the view naming approach.

@mgonto
Copy link

mgonto commented Jul 5, 2013

Commenting to get notifications

@nateabele
Copy link
Contributor

@mgonto Alternatively (to spare notifications for future dev teams), there's a menu at the bottom. ;-)

@mgonto
Copy link

mgonto commented Jul 6, 2013

Ohhh! I've never seen it before!. Sorry for the notification.

Next time I'll use the button. It should be at a more visible place.

Thanks!

@timkindberg
Copy link
Contributor Author

May be too late, but I was thinking it may be nicer and easier to read if we use ^ instead of . for traversing upward. I like how the arrow points upward. Then maybe we could use > for sibling, because the arrow points sideways. So its like up and over.... Just throwing some thoughts out there...

@ksperling
Copy link
Contributor

I like "^". A separate symbol for sibling doesn't seem necessary though; wouldn't that just be "^sibling"? or maybe "^.sibling" I kind of like the look of the latter. "^^.uncle" seems alright too, more obvious than "..uncle" I'd say.

The only drawback is that "^" from the regex analogy (and how we want to use it for URL patterns) anchors at the very top, not just one step up.

@eahutchins
Copy link

Any update on when this might be landing? I could help out if needed.

@nateabele
Copy link
Contributor

I'm actually pretty close to done with a pretty big refactor which includes these and other enhancements, and I'll be in the air with no internet for 16 hours starting tomorrow afternoon. By the time I land, UI Router will be a whole new library. ;-)

@timkindberg
Copy link
Contributor Author

Looking forward to seeing your changes!!!

@timkindberg
Copy link
Contributor Author

I'd say this is done. @nateabele?

@nateabele
Copy link
Contributor

Yup.

@christopherthielen christopherthielen removed this from the 1.5.0 milestone Nov 16, 2014
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants