Skip to content
This repository has been archived by the owner on Feb 12, 2020. It is now read-only.

Modular UI proof of concept #318

Closed
wants to merge 18 commits into from
Closed

Modular UI proof of concept #318

wants to merge 18 commits into from

Conversation

kirlat
Copy link
Member

@kirlat kirlat commented Jan 16, 2019

Here is a first take on a modular UI concept. It is not final yet, and has many imperfections. For example, there are multiple hard links to a UI controller or other components that are planned to be removed later. That's for compatibility with the existing code base. It's just an extremely involving task to change all it once. It is very error prone, too.

So I would like to offer this proof of concept for discussion first. If we decide that it's developing in the right direction, we can continue forward with it. Otherwise, we can make amends or even reverse a direction if anything is wrong with the approach.

The core concept is: all modules in a UI are dynamic. They are registered dynamically. There is no hard links between them. If module needs some other ones to function, it will check it during initialization and print a message if the check fails. That adds a safety net into a dynamic environment.

There are two major pieces that need to be shared among modules. The first one is data, and it is shared via a Vuex store modules. Each module has (usually) its own store object that's integrated into a global store. The second one is groups of functions, a public API of a module. Not all actions fit well into Vuex concept of actions and mutations. Sometimes we need to do something that is not related directly to the Vuex store data. That's what public API methods are for.

There are two types of modules: data modules (they are adapters to the libraries) and UI modules. Each UI module represents a single independent UI entity, such as a panel or a popup. All modules are registered dynamically in the UI controller's create() function. This allows a client of a UI controller (e.g. embedded lib) to create its own configuration of modules.

All registered modules are instantiated by a UI controller during an init phase. Data modules are instantiated first, the goes UI modules. That's because data modules do not have dependency on the UI modules, but the UI modules do.

During initialization a UI controller creates an instance of each module with (optional) parameters that were specified during module registration. It also inserts module's store object into a global store. All store modules are namespaced.

Each module declares its public API within its api object. UI controller does too as it has some methods that it would like to share with UI modules. Those API objects (i.e. groups of methods) are mounted into the root Vue instances with provide option. With this, all those methods becomes available to any child UI components that declare their use with inject. This allows to clearly specify what component depends on which module.

That's the concept in general. In the current implementation there is one data module (l10n) and two UI modules (popup and panel). Each of UI modules uses visible prop from the Vuex store and UI controller provides methods to change visibility of a popup or panel (so this can be done outside of the popup or panel).

Please refer to the docs for some more information on the module concept: https://github.com/alpheios-project/documentation/blob/master/development/app-architecture.md#modules

The concept of a public API is, on my opinion, important because it:

  1. Separates methods that other components use from the internal methods (i.e. emulates the private/public visibility concept).
  2. Provides a clear API that can be documented. Any consumer would know that if it needs something, it is to be found within the public API.
  3. Allow UI components to clearly declare their dependencies on specific modules with inject (for API groups) or storeModules custom prop (for store modules).
  4. Simplifies maintenance. We know that as long as public API and module's store remain the same, we're free to change anything else as it will not break external clients (hopefully!).

Please let me know what do you think. Would really appreciate your feedback! It took some time to come up with the concept, but I think it's starting to become consistent and in line with our requirements and the Vue ecosystem. Thanks!

@kirlat kirlat requested review from balmas and irina060981 January 16, 2019 17:38
@coveralls
Copy link

coveralls commented Jan 16, 2019

Pull Request Test Coverage Report for Build 2361

  • 49 of 424 (11.56%) changed or added relevant lines in 17 files are covered.
  • 751 unchanged lines in 27 files lost coverage.
  • Overall coverage decreased (-16.1%) to 19.243%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/vue/components/reskin-font-color.vue 1 2 50.0%
src/vue/vuex-modules/module.js 1 2 50.0%
src/vue/components/infl-attribute.vue 0 2 0.0%
src/vue/components/user-auth.vue 0 2 0.0%
src/lib/l10n/l10n.js 6 9 66.67%
src/vue/components/inflections.vue 1 4 25.0%
src/lib/l10n/message-bundle.js 14 20 70.0%
src/locales/locales.js 3 9 33.33%
src/vue/components/panel.vue 4 12 33.33%
src/vue/components/inflections-browser.vue 0 9 0.0%
Files with Coverage Reduction New Missed Lines %
src/lib/utility/comparable.js 1 0.0%
src/vue/components/word-usage-examples-block.vue 1 25.0%
src/lib/options/temp-storage-area.js 1 0.0%
src/vue/components/reskin-font-color.vue 1 60.0%
src/lib/state/ui-state-api.js 2 0.0%
src/lib/utility/language-names.js 2 33.33%
src/vue/components/morph.vue 3 16.67%
src/vue/components/inflections-supp-table-wide.vue 5 0.0%
src/vue/components/wordforms.vue 8 5.88%
src/vue/components/tooltip.vue 13 0.0%
Totals Coverage Status
Change from base Build 2357: -16.1%
Covered Lines: 562
Relevant Lines: 2529

💛 - Coveralls

Copy link
Member

@irina060981 irina060981 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have checked this branch with webextension (Chrome and Safari) - everything works well.
But as for embed-lib - it is not working at all:
fails with the following errors:
image

})
uiController.registerUiModule(PopupModule, {
uiController: uiController
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are passing uiController twice - as an argument and as a bind object.
Do you expect, that somehow one uiController will create UIModules for some another UIController?
What would be the workflow then?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a temporary solutions, not part of an architecture. That's for compatibility with legacy code only. The problem is that some UI components that are integrated into a popup (and panel as well) use direct links to a UI controller (which is bad and will be removed after full refactoring is complete). So we have to pass a uiController reference to the popup module to provide it to child UI components.

I've put it as a separate argument to highlight the fact that this is a temporary solution and it should go away eventually. Seeing it passed as an argument serves as a reminder that it should not be there at the end 🙂.

@irina060981
Copy link
Member

I will look closer to the code - but it needs time - from the quick look it is difficult to get an overall picture.
I will also test how something else could be integrated (for example wordlist components from a separate repo)

@irina060981
Copy link
Member

irina060981 commented Jan 17, 2019

And if to be honest, I didn't understand - what does it mean
Eases testing as probably the only thing we need to test of a module are public API and a store. The rest is not exposed and should not be unit tested (that's debatable but I think this is what our current approach is and I agree with it).

Are we going to stop using unit tests? Or it would be dependent on developer's choice?
I am using unit tests - as the stage of updating code and as the first stage for creating code (it is easier to create and test method from unit test perspective rather than from whole application perspective)

@irina060981
Copy link
Member

irina060981 commented Jan 17, 2019

Kirill, it is difficult to see an overall picture - do you have some schemas -
how could be understood for example this part of the code:

this.dataModules.forEach((m) => { m.instance = new m.ModuleClass(...m.options) })
    // Mount all registered data modules into the store
this.dataModules.forEach((m) => this.store.registerModule(m.instance.publicName, m.instance.store))
    // Mount all registered UI modules into the store
this.uiModules.forEach((m) => this.store.registerModule(m.ModuleClass.publicName, m.ModuleClass.store()))

    // Construct a public API of all data modules that will be shared using `provide`
    this.api = Object.assign(this.api, ...Array.from(this.dataModules.values()).map(m => ({ [m.instance.publicName]: m.instance.api(this.store) })))

What are class dependency links here?

Could you describe a simple scenario from methods perspective, for example :
First created UIController here
Somwhere store is created
dataModules and uiModules are created then from this method
and so on?
It would be very helpful :)

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

I have checked this branch with webextension (Chrome and Safari) - everything workd well.
But as for embed-lib - it is not working at all:

Sorry, I did not update embedded-lib to work with this version yet. I just wanted to get a feedback on the approach as soon as possible. If it will be approved, I will update embedded lib to work with it.

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

And if to be honest, I didn't understand - what does it mean
Eases testing as probably the only thing we need to test of a module are public API and a store. The rest is not exposed and should not be unit tested (that's debatable but I think this is what our current approach is and I agree with it).

Are we going to stop using unit tests? Or it would be dependent on developer's choice?
I am using unit tests - as the stage of updating code and as the first stage for creating code (it is easier to create and test method from unit test perspective rather than from whole application perspective)

We were discussing the unit test approach with @balmas and we agreed that, in general, only public functions of the object should be tested (Bridget, please correct me if I'm wrong here). The idea behind this is that each object can be considered as a black box. It has a public interface: a set of methods that are called by other objects. It also has a private interface: service methods that are called only by methods of the same object. Unfortunately, this separation is mental only right now as it is not supported on the language level, but I hope it will get there finally: https://github.com/tc39/proposal-private-methods#private-methods-and-fields. So far we can start names of private methods and fields with _ to signify that they should not be used from the outside. If the proposal mentioned above will be approved, we can replace _ with # easily.

In unit tests we need to verify that an object, being a black box as it is and given some inputs (i.e. have some methods called with certain set of argument values) should provide correct output (i.e. return correct values or produce proper state changes). If only public methods are called from the outside, then we should test public methods only. No matter what other private (internal) methods do and how do they behave exactly: as long as they do not affect the output of the public functions, we do not care (as all we care is the output of the public functions). That will give use some freedom to fiddle with object's internal structure without updating unit tests every time something inside the object is changed (as long as it does not change public methods and their output).

Does it make sense? What do you think?

@irina060981
Copy link
Member

irina060981 commented Jan 17, 2019

And if to be honest, I didn't understand - what does it mean
Eases testing as probably the only thing we need to test of a module are public API and a store. The rest is not exposed and should not be unit tested (that's debatable but I think this is what our current approach is and I agree with it).
Are we going to stop using unit tests? Or it would be dependent on developer's choice?
I am using unit tests - as the stage of updating code and as the first stage for creating code (it is easier to create and test method from unit test perspective rather than from whole application perspective)

We were discussing the unit test approach with @balmas and we agreed that, in general, only public functions of the object should be tested (Bridget, please correct me if I'm wrong here). The idea behind this is that each object can be considered as a black box. It has a public interface: a set of methods that are called by other objects. It also has a private interface: service methods that are called only by methods of the same object. Unfortunately, this separation is mental only right now as it is not supported on the language level, but I hope it will get there finally: https://github.com/tc39/proposal-private-methods#private-methods-and-fields. So far we can start names of private methods and fields with _ to signify that they should not be used from the outside. If the proposal mentioned above will be approved, we can replace _ with # easily.

In unit tests we need to verify that an object, being a black box as it is and given some inputs (i.e. have some methods called with certain set of argument values) should provide correct output (i.e. return correct values or produce proper state changes). If only public methods are called from the outside, then we should test public methods only. No matter what other private (internal) methods do and how do they behave exactly: as long as they do not affect the output of the public functions, we do not care (as all we care is the output of the public functions). That will give use some freedom to fiddle with object's internal structure without updating unit tests every time something inside the object is changed (as long as it does not change public methods and their output).

Does it make sense? What do you think?

I was looking at unit tests from another perspective. Unit tests are tests for little parts of the code (that's why they are called unit). And they have several usage purposes:

  1. "Starting from tests" - I ussually starting to develop and check the code creating simple tests.
    For example, I am working with new concordance adapter.
    I need to implement several features - geting data from API, parse data, upload to data-models objects.
    Instead of creating a large amount of code at once (to be able to check it from LexicalQuery), I am creating a step by step and check it inside unit tests without building and importing into another library
    Also it is really easy to create mock parts for any step that make code much simplier.
    And it makes me think how to make methods short to make tests simplier :)

  2. "Creating overal tests" - after creating first working prototype - I am creating unit tests for each method and while doing it I find unneeded methods, unuseful parts of the code, I add additional checks, and make different refactoring - because such test coverage allows to see all over the picture.

  3. "Describing code workflow" - after some time - when I return to the code or read other's developer code - I could read test's description and see what arguments are waited to be passed by some user's scenario.

  4. "Look at the time spent for some operations" - tests allow to calc time need for some operations, api retreiving.

  5. "Check the difference that was made to the code by another developer comparing tests" (similiar to 3) )

  6. And only the last is checking current code quality

But as I could see from your text you are using tests only for the last point.
Then it seems to me that they are not very useful for you and don't cost time needed for its updating :)

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

Kirill, it is difficult to see an overall picture - do you have some schemas -
how could be understood for example this part of the code:

this.dataModules.forEach((m) => { m.instance = new m.ModuleClass(...m.options) })
    // Mount all registered data modules into the store
this.dataModules.forEach((m) => this.store.registerModule(m.instance.publicName, m.instance.store))
    // Mount all registered UI modules into the store
this.uiModules.forEach((m) => this.store.registerModule(m.ModuleClass.publicName, m.ModuleClass.store()))

    // Construct a public API of all data modules that will be shared using `provide`
    this.api = Object.assign(this.api, ...Array.from(this.dataModules.values()).map(m => ({ [m.instance.publicName]: m.instance.api(this.store) })))

What are class dependency links here?

Could you describe a simple scenario from methods perspective, for example :
First created UIController here
Somwhere store is created
dataModules and uiModules are created then from this method
and so on?
It would be very helpful :)

I will think what diagrams could explain it the best way possible. I should have create one beforhand. Will provide a verbal explanation so far.

In create a UI controller's owner (embed-lib or webextension) registers all data and UI modules it might need. It could be explained as:

  • Data modules: "I will need these data modules in my app. They will provide me with access to certain libraries".
  • UI modules: "I am going to display those UI components to the user in my app".

init phase: each module has a store module (an object) that needs to be integrated into a global Vuex store (this.store inside a UI controller) and an api object (a group of all public methods) that needs to be integrated into a global API pool (this.api inside a UI controller).

All registered modules are stored in a map (this.dataModules for data modules and this.uiModules for the UI ones). The key of the map is the module name (a publicName static member of the module object), the value is a structure that has the following fields:
ModuleClass - a class of a module object (i.e. the constructor function in pre-ES2015 terms)
options - a set of options with which a module will be created
instance - a reference to the module object once the instance of the module will be created

So we (all references are to the ui-controller.js code inside the init method):

  1. Create instances of all registered data modules (line 299) and assign them to the instance field of the structure that holds an info about the registered module (m):
    this.dataModules.forEach((m) => { m.instance = new m.ModuleClass(...m.options) })
  2. Integrate store structures of instantiated data modules into a global Vuex store using registerModule() method of Vuex (line 301):
    this.dataModules.forEach((m) => this.store.registerModule(m.instance.publicName, m.instance.store))
  3. Integrate store structures of UI modules into a global Vuex store using registerModule() method of Vuex (line 303):
    this.uiModules.forEach((m) => this.store.registerModule(m.ModuleClass.publicName, m.ModuleClass.store()))
    Since at this moment UI modules are not created yet, store is a static method. It is a function because if it will be an object, this object will be shared across all instances of UI modules (we probably won't have more than one anyway, but it's good to have a safety net). So we've made it a function that returns a store object (similar to how Vue's data is a function, not an object).
  4. Now on line 306 we add API's of data modules into a global shared API object (this.api) in a namespaced way (i.e. there will be a prop with a module name that will hold an object will all public methods of a module):
    this.api = Object.assign(this.api, ...Array.from(this.dataModules.values()).map(m => ({ [m.instance.publicName]: m.instance.api(this.store) })))
    Module's API functions may need access to the global Vuex store. That's why api is a function that takes store as an argument and returns an object with a set of functions that refer to the store object if necessary.
  5. Then we add shared functions of a UI controller into a public API (lines 311 to 321). It is namespaced as ui:
this.api.ui = {
      // Modules
      hasModule: this.hasUiModule.bind(this), // Checks if a UI module is available
      getModule: this.getUiModule.bind(this), // Gets direct access to module.

      // Actions
      openPanel: this.openPanel.bind(this),
      closePanel: this.closePanel.bind(this),
      openPopup: this.openPopup.bind(this),
      closePopup: this.closePopup.bind(this)
    }
  1. And, finally, on line 325 we create instances of all UI modules assigning them to the instance prop of a module info structure (m). Constructor receives a global store and global API references as first two arguments, and the rest is whatever instantiation options were specified during registration.
    this.uiModules.forEach((m) => { m.instance = new m.ModuleClass(this.store, this.api, ...m.options) })

This code is technical and initialization details might slightly change later. The general idea is that we do three things (in a varying order):

  1. Create instances of all data and UI modules
  2. Register store object of all modules into a global store (that is shared across all UI components, in a namespaced way)
  3. Register api object of all module into a global API object (that is shared across all UI components, in a namespaced way)

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

I was looking at unit tests from another perspective. Unit tests are tests for little parts of the code (that's why they are called unit).

That's debatable, that's why I mentioned it 🙂. There are different approaches. I think I'll better put this into a different issue (about our approach to unit tests) as this is probably an off-topic in this PR.

I will also remove the last point about testing from the list of advantages just to not concentrate on it here (as it is not critical for the refactoring goals).

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

Moved discussion of unit testing to alpheios-project/documentation#11

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

@irina060981: I've updated description of the module concept in the docs and added a diagram that should explain the whole thing (hopefully) better: https://github.com/alpheios-project/documentation/blob/master/development/app-architecture.md#modules

@irina060981
Copy link
Member

Thank you for such a detailed explanation, it is becoming much clearer :)

Some questions then:

  1. we have some centralized - store, api, set of dataModules, set of UImodules - all of them has as a center bind option - uiController

I mean - UIControler -> store
-> api
-> dataModules
-> uiModules

All uiComponents will be presented as uiModules - or only root ones?
For example, we have inflection games as a separate repo, we want to show it as an outstanding popup, it becomes visible/hidden using button in general popup.
Where would it be declared?

  1. dataModules, api - are there only data/methods for vizualization in ui or some busines logic too?
    For example - we have a command line tool - lexical tests - it uses lexical query, creation of homonym, definitions, translation.
    Creation happens in two stages
    • the first - it creates in client-adapters and return into Lexical Query
    • the second - it makes some transformation in uiController, in vue-components
      where would it be placed in your architecture - in api or dataModules?

it is an interesting way to organize code :)

oh, also a question - I am using l10n in inflection games and wordList
how would it be integrated there? would it create separate store?

@kirlat
Copy link
Member Author

kirlat commented Jan 17, 2019

UIControler -> store
-> api
-> dataModules
-> uiModules

All uiComponents will be presented as uiModules - or only root ones?
For example, we have inflection games as a separate repo, we want to show it as an outstanding popup, it becomes visible/hidden using button in general popup.
Where would it be declared?

Only root components (the ones that create Vue root instance with new Vue()) should be UI Modules. Reusable "regular" Vue components (SFC aka Single File Components) does not make a module. They are imported and used by UI Modules.

If inflection games creates a separate popup, then it should have its own UI Module. In this "Inflection Games UI Module" inflection games code should be imported from its separate repository. Also, "Inflection Games UI Module" should crate a root view instance for inflection games in its constructor, and expose store and api object if it wants to provide public access to data (store) or methods (api).

Then, in a create() function of a UI controller, an "Inflection Games UI Module" should be registered with a UI controller's registerUiModule(). That's pretty much all that needs to be done for this case. The rest should happen behind the scenes.

If a general popup needs to open/close an inflection games popup then the following approach would work best, I believe:

  1. An "Inflection Games UI Module" should expose either open() or close() methods via an API (add those functions to the api object of an "Inflection Games UI Module"). or, if closing or opening it is just a matter of changing one prop, it could be open or close Vuex mutations. Mutations should be defined in a store object exposed by an "Inflection Games UI Module".
  2. Let's assume that an "Inflection Games UI Module" defines it's public name as inflGames.
  3. Then, in order to open and close it, a general popup needs:
    3.a Declare the use of an inflection games API with inject['inflGames'] (if inflection games exposes a methods via an API) or an inflection games store module with storeModules['inflGames'] (if it exposes mutations, this step is not strictly necessary, it's just for dependency checks).
    3.b Then, inside a general popup's SFC a this.inflGames.open() method or a this.$store.commit('inflGames/open') mutation should be used to open it. Opposite methods/mutations are used for closing.
1. dataModules, api - are there only data/methods for vizualization in ui or some busines logic too?
   For example - we have a command line tool - lexical tests - it uses lexical query, creation of homonym, definitions, translation.
   Creation happens in two stages
   
   * the first - it creates in client-adapters and return into Lexical Query
   * the second - it makes some transformation in uiController, in vue-components
     where would it be placed in your architecture - in api or dataModules?

Data modules are just adapter that allow to plug in external libraries (such as inflection games) into a Vue world. The should have no business login inside. Their goal is just to expose public methods and data of an underlying library to the UI components.

UI modules (the ones that hold root Vue instances) and UI components (i.e. SFCs) should have no business logic too. Their role is to represent the view in an MVC model. They should have presentational logic only. If they need some data (i.e. to get an inflection table), they use public API methods of an inflection tables data module that redirects request to the inflection tables library.

So the general rule is: there should no business logic in anything that is located under the vue directory in components/src. This part of the code is framework dependent, it is for UI only, and we want to have it as compact as possible. We want to keep a clear separation between business logic (external repos and lib inside components) and presentational logic (vue directory). With this separation, it would be easier to replace Vue with something else if we would decide to so.

I'm not quite familiar with lexical tests but I think the principles should be:

  1. If lexical tests process data, they should create its own data module.
  2. If a function inside lexical test is not required for the UI, it should not be exposed via a data module.
  3. I'm not sure what exact trasformations lexical test do. If it's a transformation of data, it should be a method inside lexical test library that shall be exposed via a data module's public API. A UI component would call that public API method to get processed data then. If it does some UI-related processing (i.e. decide wither to show a certain table or a block of text or not), then this logic should be inside a UI component (aka SFC) of lexical tests that displays this table or text. If you can provide more details about lexical tests functionality, I would be able to provide a more definitive answer.

It can be confusing but data module, UI modules, store module, and public API forms two separate group of entities:

  1. Modules (data modules and UI modules). Those are full-fledged JS objects that contain data, methods and so on.
  2. Public interface are store module and public API. Those are "shallow", limited JS objects that expose state data, getters, actions and mutations (for store modules) or a set of functions (for a public API).
    Modules "owns" public interface objects (store module and api) and "exposes" them to other modules and UI components. Modules itself are "owned" by a UI controller and are not accessed directly, only via their store modules or public API.
    Sorry, I have to come up with terminology on the fly and fit it into existing concepts of Vue.js, Vuex, etc. If there could be some clearer name, we can use those.

oh, also a question - I am using l10n in inflection games and wordList
how would it be integrated there? would it create separate store?

Do you mean in inflection games library or in the inflection games UI (it's important to always draw a clear line between model/view or business and presentational logic)? If L10n is needed inside the library (we're talking business logic here), then an L10n class should be imported there and used as a regular JS object. If L10n is needed by an inflection games UI (presentational logic), then I guess there will be some "Inflection Games UI Component" (.vue SFC) for that. It should use a L10n data module then. L10n data module is already loaded by a UI controller. So all the "Inflection Games UI Component" (SFC) needs to do is declare that it needs to have access to L10n data module API (with inject['l10n']) and then start using L10n data module methods as this.l10n.getMsg(). See user-auth.vue as an example of that.

@balmas
Copy link
Member

balmas commented Jan 17, 2019

(moved my comments on testing approaches to alpheios-project/documentation#11)

@alpheios-project alpheios-project deleted a comment from kirlat Jan 17, 2019
@balmas
Copy link
Member

balmas commented Jan 17, 2019

Maybe a good way to demonstrate the value of the architectural change is the following:

We currently pass data to the individual components via the data property of the component. As the complexity of the application has grown, this has become a dumping ground, of sorts, for all the different bits of information that needs to be available to the UI components and and follows no standard structure or rules. Information is sometimes duplicated in various forms, and it is very difficult to see which components will be affected by a change in format or content of a particular section of data. Further, we use this store a mixture of information about both data state and ui state and it isn't easy to sort out which is which. Just looking at inflections, we have the following properties being sent to the panel (and on to its included components):

  inflectionComponentData: {
            visible: false,
            inflectionViewSet: null,
            inflDataReady: false
    },
    inflectionBrowserData: {
            visible: false
     },
     inflectionsWaitState: false,
     inflectionsEnabled: false,
     inflectionBrowserEnabled: false,
     inflBrowserTablesCollapsed: null, // Null means that state is not set
     inflections: {
            localeSwitcher: undefined,
            viewSelector: undefined,
            tableBody: undefined
     },
     inflectionIDs: {
            localeSwitcher: 'alpheios-panel-content-infl-table-locale-switcher',
            viewSelector: 'alpheios-panel-content-infl-table-view-selector',
            tableBody: 'alpheios-panel-content-infl-table-body'
     },

(and the popup component too has a data property, inflDataReady)
and changes to the data held in these properties are sprinkled throughout methods in the UIController and the the Vue Components.

Under the new architecture, if I understand correctly, we would have an InflectionsDataModule and it would expose an api with methods for reading and mutating any state related to inflections data. Any UI component which needs to have access to inflection table data would now interact only with the api methods exposed by the InflectionsDataModule.
So instead of this very convoluted and error prone instantiation of the inflections component inside the panel component:

  <div v-show="inflectionsTabVisible" :id="inflectionsPanelID"
                 class="alpheios-panel__tab-panel alpheios-panel__tab__inflections"
                 v-if="data.inflectionComponentData.inflDataReady && data.settings && data.l10n">
                <inflections class="alpheios-panel-inflections"
                             :inflections-enabled="data.inflectionsEnabled"
                             :data="data.inflectionComponentData" :locale="data.settings.locale.currentValue"
                             :messages="data.l10n.messages" :wait-state="data.inflectionsWaitState"
                             @contentwidth="setContentWidth">
                </inflections>
            </div>

both Panel.vue and Inflections.vue would do

inject['Inflections']

and then Panel might instantiate Inflections much more simply

<div v-show="Inflections.isVisible()" :id="inflectionsPanelID"
  class="alpheios-panel__tab-panel alpheios-panel__tab__inflections"
   v-if="Inflections.dataIsReady()">
   <inflections class="alpheios-panel-inflections" @contentwidth="setContentWidth">
  </inflections>
</div>

And in the inflections component, rather than reaching back into data.inflectionComponentData to get information about which is the current inflection table to show, etc, it too would call the api methods of the InflectionsDataModule, which would provide access to all information about the state of inflections in the system and any view-related business logic as to whether or not to show them. InflectionsDataModule would itself call methods of the alpheios-inflection-tables library for all data creation, manipulation, etc.

Essentially, the DataModules are providing a public api to manage application state for a specific functional area of the system. As we have chosen Vuex as our state engine these are Vuex modules. But if at some point we switched to a different state engine, the pattern theoretically would still work. Instead of the Data Module using a Vuex $store object for state mutations, it would use something else, but the public api of the data module might not need to change at all.

This should hopefully also make testing much cleaner. Right now we have to go through alot of work to wire up the properties of the Panel and Popup Vue components for our tests, and when something changes with those data structures everything breaks. Instead we should be able to just create Mock objects which implement the public apis of the data modules and use those in both unit and integration tests as needed.

@kirlat is this an accurate description?

@balmas
Copy link
Member

balmas commented Jan 17, 2019

As far as the UI Modules are concerned, I believe these are primarily intended to make it easier for different application implementations to reuse composite components and not about state management.

Copy link
Member

@balmas balmas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm generally fine with this, but because I agree with @irina060981 that we need to be pushing ourselves to do more test-driven development, would you mind adding a unit test for the L10NModule before merging the PR? Thanks! (Also for some reason, the CI tests are failing on one of the two passes. Can you see if you can fix that too?)

@irina060981
Copy link
Member

Thank you for such a detailed explanation you both, @kirlat and @balmas .
It is not so easy to get all the picture in the head before I would start to work with it, go through the whole architecture and see how it works step by step.
I could recreate unit tests later (I don't know where it could be in the task's queue by priority :) ) - it is not very difficult for me - and allows to get a full overview :)

But for now - I like this approach, I like the independence that it could give , like the organization of the code approach here.
I don't know how it becomes to be easy to integrate separate libraries - for example inflection games has all UI Components in a separate repo. I didn't finally understand how it would be connected to the store. I think that once I would try to integrate it and see the result :)

@kirlat
Copy link
Member Author

kirlat commented Jan 18, 2019

@irina060981, @balmas: There are some very important points and ideas in this discussion, and a decision about a component's architectural change is pivotal and significant one. Because of this, it would be important that we dot the i's and cross the t's before make the architecture final. So if we could continue discussing an architecture for a couple more days more to make sure we've made all the best decisions here, it will be super beneficial for the project overall, on my opinion. There are some important decisions we're making we need to be clear about.

Essentially, the DataModules are providing a public api to manage application state for a specific functional area of the system. As we have chosen Vuex as our state engine these are Vuex modules. But if at some point we switched to a different state engine, the pattern theoretically would still work. Instead of the Data Module using a Vuex $store object for state mutations, it would use something else, but the public api of the data module might not need to change at all.

It's also important that DataModules, in addition to the public API, provide state retention. Libraries are usually stateless: if we make request to inflection tables library, it returns inflection tables for the word provided. Library does not store those tables anywhere after request is processed. However, we do need to keep those tables somewhere while they are displayed in the UI.

The thing that is bugging me is that Vuex store module and public API do have, on my opinion, an overlapping functionality and we could, in theory, eliminate one or the other, making things simpler. Let me explain why is it this way and what are some possible other ways to implement an architecture.

Let's start with Vuex store modules. It's a standard way to store and distribute state in a Vue ecosystem. In theory, we could use it for all app state management. However, there are several important issues with this approach (please correct me if I'm wrong or if you do not agree):

  1. All data attached to Vuex store is reactive. It means all object attached to the store gets Vuex getters and setters attached to monitor data changes, and, probably, Vuex uses even more than that to track state mutations. All this results in memory and performance overhead. Reactivity makes sense for data that is displayed by the UI directly. However, there can be some other internal state data that a module needs, but that will not be displayed in the UI directly (i.e. some history or user preferences info that is not displayed directly, but may affect what and how other things are displayed). Such data better not be reactive to avoid the overhead mentioned above (especially if such data is large). But there is no place for such non-reactive data in Vuex store, as far as I know.
  2. All methods that are exposed by Vuex store (mutations and actions) should be oriented toward state changes (at least that's what the guidance assumes). We could (and some people do) hack them to do anything we want, but I'm not sure how far shall we go with it. Here is an example. We want to make a lexical query that will go to several different servers and update homonym, translations, and couple of other data pieces in various places. Would it be justified to have it as a lexicalQuery action that will do all this? Would such method be more about state change or about data processing? If we have different store modules for lexemes, translations, etc and a lexicalQuery would change them all, where would the action itself belong? Logically, lexicalQuery should be in a lexicalData module of Vuex store. But would it then be acceptable for a lexicalQuery action from this store module to change data in other store modules? What do you think?

If we could accept that all data would be reactive only (and accept performance and memory consequences it might have) and agree to use Vuex actions as regular methods for everything we do, we could stick with Vuex only. No need for the public API. Things get simpler.

The public API: if we sacrifice data reactivity, we could use public API only and eliminate Vuex completely (and thus reduce our dependency on third-party tech). If we decide to go with it, all data can be stored inside a data module instance. Of course, such data is not reactive. Because of this, we have to use public API functions whenever we want to access it. So instead of accessing visible prop directly as we do with the store (this.$store.state.panel.visible) we can use a getter from a module's public API to access it: this.panel.isVisible.

There are several obvious drawback to this approach:

  1. Vue cannot cache our data. Whenever Vue redraws the UI, it will call all data access methods instead of using cached values whenever possible. The question is, however, how critical is it for us? I think we do have a UI that is pretty static and would not redraw often, comparing with something like a 60 FPS game. But still we should be careful not to degrade performance.
  2. A public API would be a mix of methods to access data (get a visible prop) and to perform actions (run a LexicalQuery). That would result in a more cluttered interface. But we could probably organize it into groups: use data to access data methods (so that it would be this.panel.data.isVisible) and actions or queries for other methods (this.panel.action.LexicalQuery(word)).

The rest are all advantages, on my opinion:

  1. We can eliminate dependency on Vuex completely. In fact, that would make data modules totally technology agnostic. It would be possible to use them with Vue.js, any other framework, or even with pure JS only.
  2. Data modules can be shifted to data libraries. That will simplify architecture and make thing easier to manage. For example, an inflection tables library could expose its own data module that will provide a state management and a public API of the library. It will serve the role similar to the one that Queries fulfill toward client adapters and maybe we can merge the two concepts together into something universal (thus simplifying things even more).

So we could, in theory, use either Vuex store modules OR data modules with its public API. That will make things much simpler, but there are some sacrifices involved. What is your opinion on that?

@kirlat
Copy link
Member Author

kirlat commented Jan 18, 2019

As far as the UI Modules are concerned, I believe these are primarily intended to make it easier for different application implementations to reuse composite components and not about state management.

This is correct. UI modules group subcomponents together to form a self-sufficient UI unit that can be reused in different apps. One can think of a UI module as of some complete real life object (i.e. a car or a house) built out of LEGO bricks (i.e. UI components aka SFCs). One can always build anything out of a set of LEGO bricks, but sometimes it is convenient to keep something assembled so one can take it and play with it right away 🙂.

Another important purpose of UI modules is that they hold the Vue root instance object (created with new Vue()) and all Vue data associated with it.

@kirlat
Copy link
Member Author

kirlat commented Jan 18, 2019

@balmas, @irina060981: Here is another important thing I would like to discuss:

We want to make a lexical query that will go to several different servers and update homonym, translations, and couple of other data pieces in various places. Would it be justified to have it as a lexicalQuery action that will do all this? Would such method be more about state change or about data processing? If we have different store modules for lexemes, translations, etc and a lexicalQuery would change them all, where would the action itself belong? Logically, lexicalQuery should be in a lexicalData module of Vuex store. But would it then be acceptable for a lexicalQuery action from this store module to change data in other store modules?

Traditionally, we started with storing data divided by functional areas. Then we used some logic inside UI components to assemble pieces from different areas together. That's because data was tightly coupled with libraries that produced it: inflection tables data was updating inflection views when asked to do so without knowing what happens with all other data requests.

However, it creates conceptual problems similar to described above (please correct me if I'm wrong here). These problems will grow once relationships between different data pieces become more complex. Also, a need to store user history and all related data puts additional pressure to it.

At any given moment of time different pieces of data can be in a different state and we have to synchronize it with some (sometimes) non-trivial logic.

Let's say there was a lookup request for one word and then a new one for the other. So for each new request we have to manually clean inflection views, morphology data, notifications etc. When new data arrives step by step we need to remember to put its pieces to some different places of storage and not to forget notify other places about its update. That's messy and error prone.

I'm wondering if there is a better way to handle this?

What if we decouple data from its libraries and group it all under a higher-level entity? Users accesses data word by word. They start from one word, then goes to the other. They may want to return to a previous word sometimes. We need to store a single word with all its related data in history.

What if we create a word (not a real name, but a conceptual term) object to keep all word-related data? If user starts a new word lookup, we create a new word object that is empty initially, but starts to fill in with data once more information comes in. In the UI, we just replace an old word object with the new one, and all data that UI displays changes with it automatically. The old word object can be destroyed, or stored into history (to indexedDB or remotely) and destroyed after that. Clean and simple. No more tedious and error-prone data cleaning (https://github.com/alpheios-project/components/blob/master/src/lib/controllers/ui-controller.js#L436-L443) (if we add a new piece of data shall we not forget to add its cleaning to the cleanContent() method). No more notification of other data pieces that some related data piece has changed. The data is either there in the word object or not, and UI displays it accordingly. The word object is a representation of an (almost) full state of an application at any given moment of time. Replacing a word with the one from history would allow us to travel back in time and back to the future if we replace a word back to the newer one 🙂.

What do you think about this concept? Would that be better than what we have now?

@irina060981
Copy link
Member

Kirill, do you expect to store and track all the data that was created during user interactions in VUEX?
Or we are talking only about data needed for UI?
If you are going to track changes in all sort of objects like inflection-tables - should all changes be done using special mutation's operations?

And about LexicalQuery - it is used from different piecies of the code - from UIController and from embed-lib for example, or from command-line tool.
Should all of them use it as a data-model, track its result in the VUEX?

@kirlat
Copy link
Member Author

kirlat commented Jan 18, 2019

Kirill, do you expect to store and track all the data that was created during user interactions in VUEX?
Or we are talking only about data needed for UI?

In the current implementation it is mixed: each module has its own instance props that are not reactive (see for example https://github.com/alpheios-project/components/blob/master/src/vue/vuex-modules/data/l10n-module.js#L10-L13) and the store part of Vuex contains data that are reactive (https://github.com/alpheios-project/components/blob/master/src/vue/vuex-modules/data/l10n-module.js#L19-L21). I'm afraid to make all store reactive because of performance implications in might have. So it's the module should decide what store needs to be reactive and what not.

So if we need to store some data that should not be reactive, we should put it to the non-reactive part of data storage (to the data module's props). In order to do that, we have to use methods of the module's public API.

But in the #318 (comment) I'm wondering if such dual approach is an overkill.

If you are going to track changes in all sort of objects like inflection-tables - should all changes be done using special mutation's operations?

If table is displayed in the UI and this is part of the store's reactive data, then yes, it is (I'm a supporter of a Vuex's strict mode approach: https://vuex.vuejs.org/guide/strict.html). But that:

  • can be done with a simple assignment of a new table to the prop that holds the old one in the mutation
  • can be done internally within an inflection data module
  • can be hidden inside a public API facade of an inflection data module:
    setTable(newTable) => { store.commit('setTable', newTable) }

And about LexicalQuery - it is used from different piecies of the code - from UIController and from embed-lib for example, or from command-line tool.
Should all of them use it as a data-model, track its result in the VUEX?

If I understand you correctly, no. LexicalQuery is a "regular" JS data object. It's not exposed to the Vue world (it's not a data module or anything). It has no data exposed to the store, and no Vuex mutations or actions should be used with it. So other pieces of code should use it the same way as they did before.

@irina060981
Copy link
Member

If I understand you correctly, no. LexicalQuery is a "regular" JS data object. It's not exposed to the Vue world (it's not a data module or anything). It has no data exposed to the store, and no Vuex mutations or actions should be used with it. So other pieces of code should use it the same way as they did before.

I am talking about this piece of your description:

A public API would be a mix of methods to access data (get a visible prop) and to perform actions (run a LexicalQuery). That would result in a more cluttered interface. But we could probably organize it into groups: use data to access data methods (so that it would be this.panel.data.isVisible) and actions or queries for other methods (this.panel.action.LexicalQuery(word))

@kirlat
Copy link
Member Author

kirlat commented Jan 18, 2019

A public API would be a mix of methods to access data (get a visible prop) and to perform actions (run a LexicalQuery). That would result in a more cluttered interface. But we could probably organize it into groups: use data to access data methods (so that it would be this.panel.data.isVisible) and actions or queries for other methods (this.panel.action.LexicalQuery(word))

In that case a LexicalQuery(word) method would be called on a "Lexical Query Data Module" (a wrapper around a regular LexicalQuery), not on a LexicalQuery itself. So the rest of the code would call a "regular" LexicalQuery and nothing would change for them. UI components, on the other hand, would use methods of "Lexical Query Data Module". "Lexical Query Data Module" might have some some reactive data or might not (then it will deliver data to UI components through its data methods).

The difference between a LexicalQuery and a "Lexical Query Data Module" is that a LexicalQuery is stateless. It keeps no data after lexical query is complete. It works well for libraries (they store lexical query state on their own), but it does not work for Vue templates, where we have to provide data objects to the template engine (we can store data in data props of UI components but then it cannot be shared; it will lead to duplication of data).

That's why there is a need for a "Lexical Query Data Module", that encapsulates LexicalQuery methods (exposed via a public API) PLUS provides state retention (it keeps results of the last lexical query until the next query overwrites it).

If we decide that a global "word" state object (#318 (comment)) will be beneficial, we could shift all state management there. Then "Lexical Query Data Module" could become stateless, or even there might be no need for "Lexical Query Data Module" at all (it could be just a lightweight wrapper that exposes LexicalQuery methods, maybe even not as a separate object, but as in interface within a UI controller).

The change of architecture of components is going to be a big one. It will require an effort. Bit if done right, it can bring many tangible benefits. If we start doing it, we should strive to do it right. That's why I think we should analyze every possibility. Only then we would come up with solutions that will satisfy all our requirements the best way possible.

@balmas
Copy link
Member

balmas commented Jan 18, 2019

Summarizing the results of a Slack discussion between myself and @kirlat :

1. Word object: this suggestion aligns closely with the work @irina060981 and I are doing on the WordList, WordItem and WordListController for the user word lists feature. It is in the separate wordlist repository right now, but will be moving back into the core data-models and components soon. A WIP class diagram:
wordlist

In this design, the wordlist controller subscribes to LexicalQuery events and populates the word object as they are received, itself issuing events that the the UserDBManager then subscribes to update the corresponding remote and local stores.

The WordItem object is intended to be an aggregation of all instances of a user looking up that word in their history of using the application, so it might not be the same as a Word object that is aggregating data related to application state, but there is clearly some overlap here and the design is probably converging on the need to have a central data module for for this. We will revisit this in more detail soon when the WordList design work is done.

2. Organization of State: we generally agreed that it would be beneficial to group state into conceptual layers:

  • application level (e.g. user preferences, application configuration)
  • language level (current language of focus)
  • object of interest level (for now, this is a word. in the future it could be a sentence or something else)
  • ui level (e.g. which tab is active, is the popup open, etc.)

3. Use of Vuex actions: it may be okay to use these for things that impact UI state but we probably should proceed with some degree of caution

4. api methods vs direct store access: we have decided to keep the api concept but limit for now the api methods to things which cannot be achieved via direct store access. So a DataModule becomes an encapsulation of a set of functionality that needs to be made available to the UI. It may declare state data for the Vuex store and it may declare api methods, or it could declare only one or the other. The UIController will gather the functionality it needs by registering the specific functional modules. Modules can be injected into the individual Vue components to provide them with functionality. The Vue components will interact directly with the reactive state properties injected into the Vuex store, and with the api methods as needed.

5. Non-reactive data: we will use properties of a data module instance to store non-reactive data

Kirlat added 7 commits January 23, 2019 18:26
…ed some helper functions to L10n. Updated tests
# Conflicts:
#	dist/alpheios-components.js
#	dist/alpheios-components.js.map
#	dist/alpheios-components.min.js
#	dist/style/style.css
#	dist/style/style.css.map
#	dist/style/style.min.css
#	package-lock.json
#	src/lib/controllers/ui-controller.js
#	src/vue/components/panel.vue
@kirlat
Copy link
Member Author

kirlat commented Jan 25, 2019

I'm merging this after integrating all upstream changes

@kirlat kirlat closed this Jan 25, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants