From 2112e4133f44de16a71f84e1187d77600613e7e4 Mon Sep 17 00:00:00 2001 From: Dominik Lubanski Date: Mon, 30 Mar 2020 10:44:59 +0200 Subject: [PATCH] feat(store): store factory for data management --- docs/README.md | 8 + docs/built-in-factories/README.md | 2 +- docs/store/README.md | 6 + docs/store/introduction.md | 92 + docs/store/model-definition.md | 231 +++ docs/store/storage.md | 213 +++ docs/store/usage.md | 371 ++++ docs/template-engine/event-listeners.md | 69 +- src/cache.js | 67 +- src/index.js | 5 +- src/store.js | 1168 +++++++++++++ src/template/core.js | 11 +- src/template/helpers.js | 80 +- src/utils.js | 2 + test/spec/cache.js | 69 +- test/spec/html.js | 137 +- test/spec/store.js | 2054 +++++++++++++++++++++++ types/index.d.ts | 76 +- 18 files changed, 4610 insertions(+), 51 deletions(-) create mode 100644 docs/store/README.md create mode 100644 docs/store/introduction.md create mode 100644 docs/store/model-definition.md create mode 100644 docs/store/storage.md create mode 100644 docs/store/usage.md create mode 100644 src/store.js create mode 100644 test/spec/store.js diff --git a/docs/README.md b/docs/README.md index 2c0879f6..48096db8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,10 +11,18 @@ - [Translation](core-concepts/translation.md) ## Built-in Factories + - [Property](built-in-factories/property.md) - [Parent & Children](built-in-factories/parent-children.md) - [Render](built-in-factories/render.md) +## Store + +- [Introduction](store/introduction.md) +- [Model Definition](store/model-definition.md) +- [Usage](store/usage.md) +- [Storage](store/storage.md) + ## Template Engine - [Overview](template-engine/overview.md) diff --git a/docs/built-in-factories/README.md b/docs/built-in-factories/README.md index bcda5da3..e182f627 100644 --- a/docs/built-in-factories/README.md +++ b/docs/built-in-factories/README.md @@ -2,4 +2,4 @@ - [Property](property.md) - [Parent & Children](parent-children.md) -- [Render](render.md) \ No newline at end of file +- [Render](render.md) diff --git a/docs/store/README.md b/docs/store/README.md new file mode 100644 index 00000000..c1e7ccf8 --- /dev/null +++ b/docs/store/README.md @@ -0,0 +1,6 @@ +# Store + +- [Introduction](introduction.md) +- [Model Definition](model-definition.md) +- [Usage](usage.md) +- [Storage](storage.md) diff --git a/docs/store/introduction.md b/docs/store/introduction.md new file mode 100644 index 00000000..408f301c --- /dev/null +++ b/docs/store/introduction.md @@ -0,0 +1,92 @@ +# Introduction + +The store provides global state management based on model definitions with built-in support for external storage. Use the store to share internal state between the components or create a container for the data from internal and external APIs. + +The feature follows all of the concepts of the library, including an extremely declarative approach. To use the store, define your model, and start using it. The same cache mechanism protects synchronization, so the state of the model is always up to date inside of the web components created by the library. The store simplifies access to different sources of data, as the communication with the model is the same, regardless of the source of the data - either from memory, or form the async APIs. + +## Concept + +To share values between components, create a singleton definition, and use it inside the component. All of the components will share the same store model instance: + +```javascript +import { store } from "hybrids"; + +const Settings = { + theme: "light", + ... +}; + +export function setDarkTheme() { + store.set(Settings, { theme: "dark" }); +} + +export default Settings; +``` + +There are a few ways to interact with the model instances, but as you can see above, the `setDarkTheme()` function does not rely on the `host` argument at all. It does not have to, as the model is a singleton, so we can use the model definition to update the model instance (the instance can be only one). All interactions with the model can be placed in a separate module, and used in the component: + +```javascript +import { store } from "hybrids"; +import Settings, { setDarkTheme } from "./settings.js"; + +const MyButton = { + settings: store(Settings), + render: ({ settings }) => html` + + +

...

+ + + `, +}; +``` + +On the other hand, let's create a web component, where we want to display the user's data fetched from the external API. Even though the source is asynchronous, the fetching process is hidden. The interaction with the model is the same as with data from memory (as shown in the above example). + +```javascript +import { store } from 'hybrids'; +import { fetch } from 'fetch-some-api'; + +export const User = { + id: true, + firstName: '', + lastName: '', + [store.connect]: { + get: id => fetch(`/users/${id}`).then(res => res.data), + }, +}; +``` + +The above `User` model definition creates a structure for each user instance with predefined default values. The `true` value of the `id` property says that `User` is an enumerable model, so there might be multiple instances of it with the unique id provided by the storage. The optional `[store.connect]` configures the source of the data. + +Then we can use it inside of the web component: + +```javascript +import { store, html } from 'hybrids'; +import { User } from './models.js'; + +const UserDetails = { + userId: '1', + user: store(User, 'userId'), + render: ({ user }) => html` +
+ ${store.pending(user) && `Loading...`} + ${store.error(user) && `Something went wrong...`} + + ${store.ready(user) && html` +

${user.firstName} ${user.lastName}

+ `} +
+ `, +} +``` + +The `UserDetails` component uses `store` factory, which connects `user` property to its model instance by provided `userId`. Take a closer look, that there is no fetching process. It is made under the hood by the store. If not directly defined, the model instances are permanently cached, so the storage is called only once (the cache might be set to a time-based value). + +The store provides three guards (like `store.ready()`), which return information about the current state of the model instance. In that matter, the store is unique as well - there might be more than one guard, which results in truthy value. + +For example, if the store looks for a new model (when `userId` changes), it still returns the last model until the new one is ready. However, the template will show the loading indicator as well. On the other hand, if the fetching process fails, the component still contains the last value, but also the error is shown. Moreover, the guards can work with any data passed from the store, so you might create a standalone web component for displaying your loading & error states instead of using guards directly in each template! + +Finally, the most important fact is that from the perspective of the `UserDetails` component, how the `User` data is fetched is irrelevant. The only thing that you care about most is what kind of data you need and how you want to use it. diff --git a/docs/store/model-definition.md b/docs/store/model-definition.md new file mode 100644 index 00000000..2a28fc35 --- /dev/null +++ b/docs/store/model-definition.md @@ -0,0 +1,231 @@ +# Model Definition + +A store model definition is a plain object with a JSON-like structure, which provides default values for the model instances. The model definition creates its own global space for the data. The access to data is based on the reference to the definition, so there is no register step, which should be done programmatically. You just define the model structure, and use it with the store. + +The model definition might be a singleton with only one instance, or it can represent multiple instances with unique identifiers. Each instance of the model definition is immutable, so updating its state always produces a new version of the model. However, models can reference each other. The instance itself does not have to be updated if its related model changes - the immutable is only bound between them, not the values. + +## Type + +```javascript +const Model = { + id?: true, + ... +} +``` + +The store supports three types of model definitions: singleton with only one instance, enumerables with multiple instances, and listing enumerable models. Each type creates its own space for the cache in the memory. + +### Singleton & Eumerable + +The `id` property is an indicator for the store if the model has multiple instances, or it is a singleton. The only valid value for the `id` field is `true`. Otherwise, it should not be defined at all. For example, you may need only one instance of the `Profile` model of the current logged in user, but it can reference an enumerable `User` model. + +The value of the identifier can be a `string`, or an `object` record (a map of primitive values). The latter is helpful for model definitions, which depend on parameters rather than on string id. For example, `SearchResult` model definition can be identified by `{ query, order, ... }` map of values. + +Model instances created on the client-side have unique string id by default using UUID v4 generator. The external storage can use client-side generated id or return its identifier when a new instance is created. + +### Listing + +The store supports a listing model definition based on the enumerable model wrapped in the array (`[Model]`). This type returns an array of model instances. For convenience, a model definition from the array creates the reference, so the listing mode does not have to be defined before usage, as it will always reference the same definition. + +```javascript +store.get([Model]) === store.get([Model]) +``` + +Listing memory-based models will return all instances of the definition. Listing type can also be used for models with external storage (more information you can find in [Storage](./storage.md) section). + +```javascript +import { store, html } from 'hybrids'; + +const Todo = { + id: true, + desc: '', + checked: false, +}; + +const MyElement = { + todoList: store([Todo]), + render: ({ todoList }) => html` + + `, +}; +``` + +The listing type is the best for models, which can be represented as an array (like memory-based models). If the listing requires additional metadata (like pagination, offset, etc.), you should create a separate model definition with a nested array of models. + +```javascript +const TodoList = { + items: [Todo], + offset: 0, + limit: 0, + [store.connect]: { ... }, +}; +``` + +The listing type respects the `cache` option of the model, but the `loose` option is always turned on (it is the same feature as explained in the cache invalidation section for nested array). It means that the user's change to any instance of the model will invalidate the cache, and the next call for the list will fetch data again. + +## Structure + +The model definition allows a subset of the JSON standard with minor changes. The model instance serializes to a string in form, which can be sent over the network without additional modification. + +### Primitive Value + +```javascript +const Model = { + firstName: "", + count: 0, + checked: false, + ... +}; +``` + +The model definition supports primitive values of `string`, `number`, or `boolean` type. The default value defines the type of the property. It works similarly to the [transform feature](./property.md#transform) of the property factory. For example, for strings, it is the `String(value)`. + +#### Validation + +The store supports validation for `string` and `number` values. Use `store.value()` method instead of passing the default value directly: + +```javascript +const Model = { + firstName: store.value(""), + count: store.value(0, (val) => val > 10, "Value must be bigger than 10"), + ..., +}; +``` + +```typescript +store.value(defaultValue: string | number, validate?: fn | RegExp, errorMessage?: string): String | Number +``` + +* **arguments**: + * `defaultValue` - `string` or `number` value + * `validate` - a validation function - `validate(val, key, model)`, which should return `false`, error message or throws when validation fails, or a RegExp instance. If omitted, the default validation is used, which fails for empty string and `0`. + * `errorMessage` - optional error message used when validation fails +* **returns**: + * a `String` or `Number` instance + +The validation runs only for the `store.set()` method (it protects the values only when the user interacts with the data). The rejected `Error` instance contains `err.errors` object, where all of the validation errors are listed by the property names (you can read more about how to use it in the [`Usage`](./usage.md#draft-mode) section). + +### Computed Value + +```javascript +const Model = { + firstName: 'Great', + lastName: 'name!', + // Model instance will have not enumerable property `fullName` + fullName: ({ firstName, lastName }) => `${firstName} ${lastName}`, +} + +// Somewhere with the model instance... +console.log(model.fullName); // logs "Great name!" +``` + +The computed property allows defining value based on other properties from the model. Its value is only calculated if the property is accessed for the first time. As the model instance is immutable, the result value is permanently cached. Also, as it results from other values of the model, the property is non-enumerable to prevent serializing its value to the storage (for example, `JSON.stringify()` won't use its value). + +### Nested Object + +The model definition supports two types of nested objects. They might be internal, where the value is stored inside the model instance, or they can be external as model instances bound by the id (with separate memory space). + +The nested object structure is similar to the external model definition. It could be used as a primary model definition as well. The store must have a way to distinguish if the definition's intention is an internal structure or an external model definition. You can find how the store chooses the right option below. + +#### Object Instance (Internal) + +```javascript +const Model = { + internal: { + value: 'test', + number: 0, + ... + }, +}; +``` + +If the nested structure does not provide `id` property, and it is not connected to the storage (by the `[store.connect]`), the store assumes that this is the internal part of the parent model definition. As a result, the data will be attached to the model, and it is not shared with other instances. Each model instance will have its nested values. All the rules of the model definition apply, so nested objects can have their own deep nested structures, etc. + +#### Model Definition (External) + +```javascript +const ModelWithId = { + // It is enumerable + id: true, + ... +}; + +const SingletonFromStorage = { + ... + // It connects to the external storage + [store.connect]: { ... }, +}; + +const Model = { + externalWithId: ModelWithId, + externalSingleton: SingletonFromStorage, +}; +``` + +If the nested object is a model definition with `id` property or it is connected to the storage, the store creates a dynamic binding to the global model instance. Instead of setting value in-place, the property is a getter, which calls the store for a model instance by the id (for singletons, the identifier is always set to `undefined`). The relation is only one way - its the parent model, which creates a connection to the nested model. The related model does not know about the connection automatically - it has only properties defined in its definition. + +The value for that property fetched from the parent storage might be a model instance data (an object with values) or a valid identifier (object identifiers are not supported here, as they are used as a data source). If the parent's model storage contains full data of the related model, it is treated as the newest version of that model instance, and the values of the instance are replaced with the result. Otherwise, the store will use and save the returned identifier. After all, calling that property will invoke the store to get a proper model instance by its definition. It means that you can create relations between data even between separate storages. The store will take care to get the data for you. + +To indicate no relation, set the property to `null` or `undefined`. In that case, the value of the nested external object will be set to `undefined`. + +### Nested Array + +The store supports nested arrays in a similar way to the nested objects described above. The first item of the array represents the type of structure - internal (primitives or object structures), or external reference to enumerable model definitions (by the `id` property). Updating the nested array must provide a new version of the array. You cannot change a single item from the array in-place. + +#### Primitives or Nested Objects (Internal) + +```javascript +const Model = { + permissions: ['user', 'admin'], + images: [ + { url: 'https://example.com/large.png', size: 'large' }, + { url: 'https://example.com/medium.png', size: 'medium' }, + ], +}; +``` + +If the first item of the array is a primitive value or an internal object instance (according to the rules defined for nested objects), the array's content will be unique for each model instance. The default value of the nested array in that mode can have more items, which will be created using the first element's model definition. + +#### Model Definitions (External) + +```javascript +import OtherModel from './otherModel.js'; + +const Model = { + items: [OtherModel], +}; +``` + +If the first item of the array is an enumerable model definition, the property represents a list of external model instances mapped by their ids. The parent model's storage may provide a list of data for model instances or a list of identifiers. The update process and binding between models work the same as for a single nested object. + +### Cache Invalidation + +By default, the store does not invalidate the cached value of the parent model instance when nested external models change. Because of the nature of the binding between models, when the nested model updates its state, it will be reflected without the parent model's update. + +However, the list in the parent model might be related to the current state of nested models. For example, the model definition representing a paginated structure ordered by name must update when one of the nested model changes. After the change, the result pages might have a different order. To support that case, you can pass a second object to the nested array definition with `loose` option: + +```javascript +import { store } from 'hybrids'; +import User from './user.js'; + +const UserList = { + id: true, + users: [User, { loose: true }], + ..., + [store.connect]: (params) => api.get('/users/search', params), +}; + +const pageOne = store.get(UserList, { page: 1, query: '' }); + +// Updates some user and invalidates cached value of the `pageOne` model instance +store.set(pageOne.users[0], { name: 'New name' }); +``` + +To prevent an endless loop of fetching data, the cached value of the parent model instance with a nested array with `loose` option set to `true` only invalidates if the `store.set` method is used. Updating the state of the nested model definition by fetching new values by `store.get` action won't invalidate the parent model. Get action still respects the `cache` option of the parent storage (it's infinite for the memory-based models). This feature only tracks changes made by the user. If you need a high rate of accuracy of the external data, you should set a very low value of the `cache` option in the storage, or even set it to `false`. diff --git a/docs/store/storage.md b/docs/store/storage.md new file mode 100644 index 00000000..ed439662 --- /dev/null +++ b/docs/store/storage.md @@ -0,0 +1,213 @@ +# Storage + +A model definition can be connected to a custom data source with the cache mechanism attached to it. The storage supports synchronous sources (memory, localStorage, etc.), as well as external APIs. + +## Memory + +By default, the model definition uses memory storage attached to the persistent cache. + +### Singleton + +For the singleton model definition, the store always returns an instance of the model. It means that the model does not have to be initialized before the first usage. + +```javascript +const Globals = { + someValue: 123, +}; + +// logs: 123 +console.log(store.get(Globals).someValue); + +// logs: true +console.log(store.get(Globals) === store.get(Globals)); +``` + +When the model is deleted, within the next call the default values will be returned: + +```javascript +store.set(Globals, null).then(() => { + // logs: 123 + console.log(store.get(Globals).someValue); +}) +``` + +### Enumerable + +For the enumerable model definition, the memory storage supports listing with all of the model instances. However, it does not support passing `string` or `object` id (it always returns all of the instances). + +```javascript +const Todo = { + id: true, + desc: "", + checked: false, +}; + +// Logs an array with all of the instances of the Todo model +console.log(store.get([Todo])); +``` + +## External + +To define the external source of the data set the `[store.connect]` property in the model definition: + +```javascript +const Model = { + ..., + [store.connect]: { + get: (id) => {...}, + set?: (id, values, keys) => {...}, + list?: (id) => {...}, + cache?: boolean | number [ms] = true, + }, +}; +``` + +### get + +```typescript +get: (id: string | object) => object | null | Promise +``` + +* **arguments**: + * `id` - `undefined`, a `string` or `object` model instance identifier +* **returns (required)**: + * `null`, an `object` representing model values or a `promise` resolving to the values for the model + +If the storage definition only contains `get` method, you can use shorter syntax using a function as a value of the `[store.connect]` property (it works similar to the property descriptor): + +```javascript +const Model = { + ..., + // equals to { get: (id) => {...} } + [store.connect]: (id) => {...}, +}; +``` + +### set + +```typescript +set?: (id: undefined | string | object, values: object, keys: [string]) => object | null | Promise +``` + +* **arguments**: + * `id` - `undefined`, a `string` or `object` model instance identifier + * `values` - a model draft with updated values + * `keys` - a list of names of the updated properties +* **returns (required)**: + * `null`, an `object` representing model values or a `promise` resolving to the values for the model + +When `set` method is omitted, the model definition became read-only, and it is not possible to update values. As the `store.set()` method supports partial values, the `keys` argument contains a list of actually updated properties (it might be helpful if the source supports partial update). + +The configuration does not provide a separate method for creating a new instance of the model definition, but the `id` field is then set to `undefined` (for singleton model definition it is always `undefined`). Still, the `values` contains the `id` property generated on the client-side. + +```javascript +{ + set(id, values) { + if (id === undefined) { + return api.create(values); + } + + return api.update(id, values); + } +} +``` + +### list + +```typescript +list?: (id: undefined | string | object) => [object] | Promise<[object]> +``` + +* **arguments**: + * `id` - `undefined`, a `string` or `object` model instance identifier +* **returns (required)**: + * an `array` of model instances or a `promise` resolving to the `array` of models + +Use `list` method to support the [listing type](./model-definition.md#listing-mode) of the enumerable model definition. The listing type creates its own cache space mapped by the `id`, respecting the `cache` setting of the model. You can support passing query parameters, string values, or skip the `id` and return all instances. + +```javascript +const Movie = { + id: true, + title: "", + ... + [store.connect]: { + get: (id) => movieApi.get(id), + list: ({ query, year }) => movieApi.search({ query, year }), + }, +}; + +const MovieList = { + query: '', + year: 2020, + movies: store([Movie], (host) => ({ query: host.query, year: host.year })), + render: ({ query, year, movies }) => html` + + +
    + ${store.ready(movies) && movies.map(movie => html`
  • ...
  • `)} +
+ `, +} +``` + +In the above example, the `list` method uses the search feature of the API. Using the listing type, we can display a result page with movies filtered by query and year. However, the result of the listing mode cannot contain additional metadata. For such a case, create a separate definition with a nested array of models. + +```javascript +import Movie from "./movie.js"; + +const MovieSearchResult = { + items: [Movie], + offset: 0, + limit: 0, + [store.connect]: { + get: ({ query, year}) => movieApi.search({ query, year }), + }; +}; +``` + +### cache + +```typescript +cache?: boolean | number [ms] = `true` +``` + +`cache` option sets the expiration time for the cached value of the model instance. By default, it is set to `true`, which makes the cache persistent. It means that the data source is called only once or if the cache is invalidated manually (explained below). + +If `cache` is set to a `number`, it represents a time to invalidation counted in milliseconds. Expired models are not automatically fetched, or removed from the memory. Only the next call for the model after its expiration fetches data from the source again. + +For the high-frequency data, set `cache` value to `false` or `0`. Then, each call for the model will fetch data from the store. Usually, the store is used inside of the component properties. In that case, its value is cached also on the host property level, so updating other properties won't trigger fetching the model again (use cache invalidation for manual update). + +#### Invalidation + +Models with memory or external storage use a global cache mechanism based on the model definition reference. Model instances are global, so the cache mechanism cannot automatically predict which instance is no longer required. Because of that, the store provides `store.clear()` method for invalidating all of the model instances by the model definition or specific instance of the model. + +```typescript +store.clear(model: object, clearValue?: boolean = true) +``` + +* **arguments**: + * `model` - a model definition (for all instances) or a model instance (for a specific one) + * `clearValue` - indicates if the cached value should be deleted (`true`), or it should only notify the cache mechanism, that the value expired, but leaves the value untouched (`false`) + +For example, it might be useful to set the `clearValue` to `false` for the case when you want to implement the refresh button. Then, the values stay in the cache, but the store will fetch the next version of the models. + +```javascript +import Email from "./email.js"; + +function refresh() { + store.clear([Email], false); +} + +const MyElement = { + emails: store([Email]), + render: ({ emails }) => html` + + + ${store.ready(emails) && ...} + `, +} +``` + +#### Garbage Collector + +The `store.clear()` method also works as a garbage collector for unused model instances. Those that are not a dependency of any component property will be deleted entirely from the cache registry (as they would never exist) protecting from the memory leaks. It means, that even if you set `clearValue` to `false`, those instances that are not currently attached to the components, will be permanently deleted. diff --git a/docs/store/usage.md b/docs/store/usage.md new file mode 100644 index 00000000..96e7d675 --- /dev/null +++ b/docs/store/usage.md @@ -0,0 +1,371 @@ +# Usage + +```javascript +import { store } from "hybrids"; +``` + +The store provides two ways to interact with the data - the `store()` factory and two methods for direct access: `store.get()` and `store.set()`. Usually, all you need is a factory, which covers most of the cases. Direct access might be required for more advanced structures. For example, it is straightforward to create a paginated view with a list of data with the factory. Still, for infinite scroll behavior, you should display data from all of the pages, so you should call `store.get()` directly inside of the property getter. + +## Direct + +Even though the factory might be used more often than the methods, its implementation is based on `store.get()` and `store.set()` methods. Because of that, it is important to understand how they work. + +The most important are the following ground rules: + +* `store.get()` always returns the current state of the model instance **synchronously** +* `store.set()` always updates model instance **asynchronously** using `Promise` API +* `store.get()` and `store.set()` always return an **object** (model instance, placeholder or promise instance) + +Those unique principals unify access to async and sync sources. From the user perspective, it is irrelevant what kind of data source has the model. The store provides a placeholder type, which is returned if there is no previous value of the model instance (the model instance is not found, it is pending, or an error was returned). The placeholder protects access to its properties, so you won't use it by mistake (the guards help using the current state of the model instance properly). + +### `store.get()` + +```typescript +store.get(Model: object, id?: string | object) : object; +``` + +* **arguments**: + * `Model: object` - a model definition + * `id: string | object` - a string or an object representing identifier of the model instance +* **returns**: + * Model instance or model instance placeholder + +The store resolves data as soon as possible. If the model source is synchronous (memory-based or external sync source, like `localStorage`), the get method immediately returns an instance. Otherwise, depending on the cached value and validation, the placeholder might be returned instead. When the promise resolves, the next call to the store returns an instance. The cache mechanism takes care to notify the component that data has changed (if you need to use this method outside of the component definition, you can use `store.pending()` guard to access the returned promise). + +```javascript +const GlobalState = { + count: 0, +}; + +function incCount(host) { + store.set(GlobalState, { count: host.count + 1 }); +} + +const MyElement = { + count: () => store.get(GlobalState).count, + render: ({ count }) => html` + + `, +} +``` + +The above example uses a singleton memory-based model, so the data is available instantly. The `count` property can be returned directly inside of the property definition. Even the `count` property of the host does not rely on other properties, the `render` property will be notified when the current value of the `GlobalState` changes (keep in mind that this approach creates a global state object, which is shared between all of the component instances). + +### `store.set()` + +The `store.set()` method can create a new instance of the model or update the existing model. According to the mode, the first argument should be a model definition or a model instance. + +The set method uses Promise API regardless of the type of data source. The model values are never updated synchronously. However, the current state of the model instance is updated. After calling the set method the `store.pending()` guard will return a truthy value, up to when the promise is resolved. + +#### Create + +```typescript +store.set(Model: object, values: object) : Promise; +``` + +* **arguments**: + * `Model: object` - a model definition + * `values: object` - an object with partial values of the model instance +* **returns**: + * A promise, which resolves with the model instance + +```javascript +const Settings = { + color: "white", + mode: "lite", + ..., +}; + +// Updates only the `mode` property +store.set(Settings, { mode: "full" }).then(settings => { + console.log(settings); // logs { color: "white", mode: "full", ... } +}); +``` + +The singleton model has only one model instance, so it is irrelevant if you call `store.set` method by the model definition, or the model instance - the effect will be the same. For example, in the above code snippet, `Settings` can have a previous state, but setting new value by the model definition updates the already existing model instance. + +#### Update + +```typescript +store.set(modelInstance: object, values: object | null): Promise; +``` + +* **arguments**: + * `modelInstance: object` - a model instance + * `values: object | null` - an object with partial values of the model instance or `null` for deleting the model +* **returns**: + * A promise, which resolves with the model instance or placeholder (for model deletion) + +The only valid argument for values besides an object instance is a `null` pointer. It should be used to delete the model instance. However, as the last ground principle states, the store always returns an object. If the model instance does not exist, the placeholder is returned in the error state (with an error attached). + +```javascript +function handleDeleteUser(host) { + const { someUser } = host; + + store.set(someUser, null).then(someUser => { + // someUser is now a placeholder with attached error + console.log(store.error(someError)); // Logs an error "Not Found ..." + }); +} +``` + +The `store.set` supports partial values to update the model only with changing values. If you use nested object structures, you can update them partially as well: + +```javascript +store.set(myUser, { address: { street: "New Street" }}); +``` + +The above action will update only the `myUser.address.street` value leaving the rest properties untouched (it will copy them from the last state of the model). + +## Factory + +The factory defines a property descriptor connected to the store depending on the model definition configuration. + +```typescript +store(Model: object, options?: id | { id?: string | (host) => any, draft?: boolean }): object +``` + +* **arguments**: + * `Model: object` - a model definition + * `options` - an object with the following properties or the shorter syntax with the below `id` field value + * `id` - a `host` property name, or a function returning the identifier using the `host` + * `draft` - a boolean switch for the draft mode, where the property returns a copy of the model instance for the form manipulation +* **returns**: + * hybrid property descriptor, which resolves to a store model instance + +### Writable + +If the model definition storage supports set action, the defined property will be writable (by the `store.set()` method). + +```javascript +function setDarkTheme(host, event) { + // updates `theme` property of the user model instance + host.user = { theme: "dark" }; +} + +const MyElement = { + userId: "1", + user: store(User, { id: "userId" }), + render: ({ user }) => html` + ... + + `, +}; +``` + +### Singleton + +If the model definition is a singleton, the `id` field is irrelevant, so you can access the instance without using options. + +```javascript +import { Settings } from "./models.js"; + +const MyElement = { + settings: store(Settings), + color: ({ settings }) => settings.darkTheme ? "white" : "black", + ... +}; +``` + +### Enumerable + +For the enumerable model definition, the `id` must be set (except the draft mode), either by the property name or a function. + +```javascript +import { User, SearchResult } from "./models.js"; + +const MyElement = { + // Id from the host property (can be changed) + userId: "1", + user: store(User, "userId"), // using shorter syntax, equals to { id: "userId" } + + + // Id from the host properties + order: "asc", + query: "", + searchResult: store(SearchResult, ({ order, query }) => { + return { order, query }; + }), +}; +``` + +#### The Last Value + +The significant difference between using `store.get()` method directly and the factory for enumerable models is a unique behavior implemented for returning the last instance even though the identifier has changed. The get method always returns the data according to the passed arguments. However, The factory caches the last value of the property, so when the id changes, the property still returns the previous state until the next instance is ready. + +```javascript +import { User } from "./models.js"; + +function setNextPage(host) { + host.page += 1; +} + +const MyElement = { + page: 1, + userList: store([User], "page"), + render: ({ userList, page }) => html` + + +
    + ${store.ready(userList) && userList.map(user => html` +
  • ${user.firstName} ${user.lastName}
  • + `.key(user.id))} +
+ + + `, +}; +``` + +Let's assume that the above `UserList` model definition is enumerable, and the page property sets the id for the external storage. When the property gets new data from the store, it returns the last page with the current loading state (from the next value). You can avoid a situation when the user sees an empty screen with a loading indicator - the old data are displayed until the new page is ready to be displayed. However, you still have the option to hide data immediately - use `store.pending()` guard for it. + +### Draft Mode + +The draft mode is especially useful when working with forms. It creates a copy of the model instance or creates a new one in the memory based on the provided model definition. When all of the changes in the draft are finished, use the `store.submit(draft)` method to create or update the primary model instance. For the protection against memory leaks, copied model instances are deleted when web components are disconnected in the draft mode. + +```javascript +import { User } from "./models.js"; + +function submit(host, event) { + event.preventDefault(); + + // Creates a real `User` model instance + store.submit(host.user).then(() => { + // Clears values in the form + host.user = null; + }); +} + +const CreateUserForm = { + user: store(User, { draft: true }), + render: ({ user }) => html` +
+
+ +
+ +
+ +
+
+ `, +} +``` + +Use `store.value()` in the definition to validate values, and the `html.set(model, propertyPath)` helper from the template engine to update values without custom side effects (read more about the `html.set` for the store in the [`Event Listeners`](../template-engine/event-listeners.md#form-elements) section of the template engine documentation). + +```javascript +const MyInput = { + model: null, + name: "", + error: ({ model }) => store.error(model, name), + render: ({ model, name }) => html` +
+ + ${error && html`

${error}

`} +
+ `, +} + +const MyUserForm = { + userId: "", + user: store(User, { id: "userId", draft: true }), + render: ({ user }) => html` + + + + + `, +}; +``` diff --git a/src/cache.js b/src/cache.js index 2f45d624..ce8ca9e1 100644 --- a/src/cache.js +++ b/src/cache.js @@ -27,6 +27,17 @@ export function getEntry(target, key) { return entry; } +export function getEntries(target) { + const result = []; + const targetMap = entries.get(target); + if (targetMap) { + targetMap.forEach(entry => { + result.push(entry); + }); + } + return result; +} + function calculateChecksum(entry) { let checksum = entry.state; if (entry.deps) { @@ -60,7 +71,7 @@ function restoreDeepDeps(entry, deps) { } const contextStack = new Set(); -export function get(target, key, getter) { +export function get(target, key, getter, validate) { const entry = getEntry(target, key); if (contextStack.size && contextStack.has(entry)) { @@ -77,7 +88,11 @@ export function get(target, key, getter) { } }); - if (entry.checksum && entry.checksum === calculateChecksum(entry)) { + if ( + ((validate && validate(entry.value)) || !validate) && + entry.checksum && + entry.checksum === calculateChecksum(entry) + ) { return entry.value; } @@ -137,25 +152,61 @@ export function set(target, key, setter, value) { } } -export function invalidate(target, key, clearValue) { - if (contextStack.size) { - throw Error( - `Invalidating property in chain of get calls is forbidden: '${key}'`, - ); +const gcList = new Set(); +function deleteEntry(entry) { + if (!gcList.size) { + requestAnimationFrame(() => { + gcList.forEach(e => { + if (!e.contexts || (e.contexts && e.contexts.size === 0)) { + const targetMap = entries.get(e.target); + targetMap.delete(e.key); + } + }); + gcList.clear(); + }); } - const entry = getEntry(target, key); + gcList.add(entry); +} +function invalidateEntry(entry, clearValue, deleteValue) { entry.checksum = 0; entry.state += 1; dispatchDeep(entry); + if (deleteValue) deleteEntry(entry); if (clearValue) { entry.value = undefined; } } +export function invalidate(target, key, clearValue, deleteValue) { + if (contextStack.size) { + throw Error( + `Invalidating property in chain of get calls is forbidden: '${key}'`, + ); + } + + const entry = getEntry(target, key); + invalidateEntry(entry, clearValue, deleteValue); +} + +export function invalidateAll(target, clearValue, deleteValue) { + if (contextStack.size) { + throw Error( + "Invalidating all properties in chain of get calls is forbidden", + ); + } + + const targetMap = entries.get(target); + if (targetMap) { + targetMap.forEach(entry => { + invalidateEntry(entry, clearValue, deleteValue); + }); + } +} + export function observe(target, key, getter, fn) { const entry = getEntry(target, key); entry.observed = true; diff --git a/src/index.js b/src/index.js index cdf411c9..3e8f8fea 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,8 @@ export { default as property } from "./property.js"; export { default as parent } from "./parent.js"; export { default as children } from "./children.js"; export { default as render } from "./render.js"; - -export { dispatch } from "./utils.js"; +export { default as store } from "./store.js"; export { html, svg } from "./template/index.js"; + +export { dispatch } from "./utils.js"; diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..ad4b32e9 --- /dev/null +++ b/src/store.js @@ -0,0 +1,1168 @@ +/* eslint-disable no-use-before-define */ +import * as cache from "./cache.js"; +import { storePointer } from "./utils.js"; + +/* istanbul ignore next */ +try { process.env.NODE_ENV } catch(e) { var process = { env: { NODE_ENV: 'production' } }; } // eslint-disable-line + +export const connect = `__store__connect__${Date.now()}__`; +const definitions = new WeakMap(); + +function resolve(config, model, lastModel) { + if (lastModel) definitions.set(lastModel, null); + definitions.set(model, config); + + return model; +} + +function resolveWithInvalidate(config, model, lastModel) { + resolve(config, model, lastModel); + + if ((config.external && model) || !lastModel || error(model)) { + config.invalidate(); + } + + return model; +} + +function sync(config, id, model, invalidate) { + cache.set( + config, + id, + invalidate ? resolveWithInvalidate : resolve, + model, + true, + ); + return model; +} + +let currentTimestamp; +function getCurrentTimestamp() { + if (!currentTimestamp) { + currentTimestamp = Date.now(); + requestAnimationFrame(() => { + currentTimestamp = undefined; + }); + } + return currentTimestamp; +} + +const timestamps = new WeakMap(); + +function getTimestamp(model) { + let timestamp = timestamps.get(model); + + if (!timestamp) { + timestamp = getCurrentTimestamp(); + timestamps.set(model, timestamp); + } + + return timestamp; +} + +function setTimestamp(model) { + timestamps.set(model, getCurrentTimestamp()); + return model; +} + +function setupStorage(storage) { + if (typeof storage === "function") storage = { get: storage }; + + const result = { cache: true, ...storage }; + + if (result.cache === false || result.cache === 0) { + result.validate = cachedModel => + !cachedModel || getTimestamp(cachedModel) === getCurrentTimestamp(); + } else if (typeof result.cache === "number") { + result.validate = cachedModel => + !cachedModel || + getTimestamp(cachedModel) + result.cache > getCurrentTimestamp(); + } else if (result.cache !== true) { + throw TypeError( + `Storage cache property must be a boolean or number: ${typeof result.cache}`, + ); + } + + return Object.freeze(result); +} + +function memoryStorage(config) { + return { + get: config.enumerable ? () => {} : () => config.create({}), + set: config.enumerable + ? (id, values) => values + : (id, values) => (values === null ? { id } : values), + list: + config.enumerable && + function list(id) { + if (id) { + throw TypeError(`Memory-based model definition does not support id`); + } + + return cache.getEntries(config).reduce((acc, { key, value }) => { + if (key === config) return acc; + if (value && !error(value)) acc.push(key); + return acc; + }, []); + }, + }; +} + +function bootstrap(Model, nested) { + if (Array.isArray(Model)) { + return setupListModel(Model[0], nested); + } + return setupModel(Model, nested); +} + +function getTypeConstructor(type, key) { + switch (type) { + case "string": + return v => (v !== undefined && v !== null ? String(v) : ""); + case "number": + return Number; + case "boolean": + return Boolean; + default: + throw TypeError( + `The value of the '${key}' must be a string, number or boolean: ${type}`, + ); + } +} + +const stateSetter = (h, v) => v; +function setModelState(model, state, value = model) { + cache.set(model, "state", stateSetter, { state, value }, true); + return model; +} + +const stateGetter = (model, v = { state: "ready", value: model }) => v; +function getModelState(model) { + return cache.get(model, "state", stateGetter); +} + +// UUID v4 generator thanks to https://gist.github.com/jed/982883 +function uuid(temp) { + return temp + ? // eslint-disable-next-line no-bitwise, no-mixed-operators + (temp ^ ((Math.random() * 16) >> (temp / 4))).toString(16) + : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid); +} + +const validationMap = new WeakMap(); + +function resolveKey(Model, key, config) { + let defaultValue = config.model[key]; + let type = typeof config.model[key]; + + if (defaultValue instanceof String || defaultValue instanceof Number) { + const check = validationMap.get(defaultValue); + if (!check) { + throw TypeError( + stringifyModel( + Model, + `You must use primitive ${typeof defaultValue.valueOf()} value for '${key}' property of the provided model definition`, + ), + ); + } + + defaultValue = defaultValue.valueOf(); + type = typeof defaultValue; + + config.checks.set(key, check); + } + + return { defaultValue, type }; +} + +function stringifyModel(Model, msg) { + return `${msg}:\n\n${JSON.stringify( + Model, + (key, value) => { + if (key === connect) return undefined; + return value; + }, + 2, + )}\n\n`; +} + +const _ = (h, v) => v; + +const resolvedPromise = Promise.resolve(); +const configs = new WeakMap(); +function setupModel(Model, nested) { + if (typeof Model !== "object" || Model === null) { + throw TypeError(`Model definition must be an object: ${typeof Model}`); + } + + let config = configs.get(Model); + + if (config && !config.enumerable) { + if (nested && !config.nested) { + throw TypeError( + stringifyModel( + Model, + "Provided model definition for nested object already used as a root definition", + ), + ); + } + + if (!nested && config.nested) { + throw TypeError( + stringifyModel( + Model, + "Nested model definition cannot be used outside of the parent definition", + ), + ); + } + } + + if (!config) { + const storage = Model[connect]; + if (typeof storage === "object") Object.freeze(storage); + + let invalidatePromise; + const placeholder = {}; + const enumerable = hasOwnProperty.call(Model, "id"); + const checks = new Map(); + + config = { + model: Model, + external: !!storage, + enumerable, + nested: !enumerable && nested, + placeholder: id => + Object.freeze(Object.assign(Object.create(placeholder), { id })), + isInstance: model => Object.getPrototypeOf(model) !== placeholder, + invalidate: () => { + if (!invalidatePromise) { + invalidatePromise = resolvedPromise.then(() => { + cache.invalidate(config, config, true); + invalidatePromise = null; + }); + } + }, + checks, + }; + + config.storage = setupStorage(storage || memoryStorage(config, Model)); + + const transform = Object.keys(Object.freeze(Model)) + .filter(key => key !== connect) + .map(key => { + if (key !== "id") { + Object.defineProperty(placeholder, key, { + get() { + throw Error( + `Model instance in ${ + getModelState(this).state + } state - use store.pending(), store.error(), or store.ready() guards`, + ); + }, + enumerable: true, + }); + } + + if (key === "id") { + if (Model[key] !== true) { + throw TypeError( + "The 'id' property in model definition must be set to 'true' or not be defined", + ); + } + return (model, data, lastModel) => { + let id; + if (lastModel) { + id = lastModel.id; + } else if (hasOwnProperty.call(data, "id")) { + id = String(data.id); + } else { + id = uuid(); + } + + Object.defineProperty(model, "id", { value: id, enumerable: true }); + }; + } + + const { defaultValue, type } = resolveKey(Model, key, config); + + switch (type) { + case "function": + return model => { + Object.defineProperty(model, key, { + get() { + return cache.get(this, key, defaultValue); + }, + }); + }; + case "object": { + if (defaultValue === null) { + throw TypeError( + `The value for the '${key}' must be an object instance: ${defaultValue}`, + ); + } + + const isArray = Array.isArray(defaultValue); + + if (isArray) { + const nestedType = typeof defaultValue[0]; + + if (nestedType !== "object") { + const Constructor = getTypeConstructor(nestedType, key); + const defaultArray = Object.freeze( + defaultValue.map(Constructor), + ); + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + if (!Array.isArray(data[key])) { + throw TypeError( + `The value for '${key}' property must be an array: ${typeof data[ + key + ]}`, + ); + } + model[key] = Object.freeze(data[key].map(Constructor)); + } else if (lastModel && hasOwnProperty.call(lastModel, key)) { + model[key] = lastModel[key]; + } else { + model[key] = defaultArray; + } + }; + } + + const localConfig = bootstrap(defaultValue, true); + + if (localConfig.enumerable && defaultValue[1]) { + const nestedOptions = defaultValue[1]; + if (typeof nestedOptions !== "object") { + throw TypeError( + `Options for '${key}' array property must be an object instance: ${typeof nestedOptions}`, + ); + } + if (nestedOptions.loose) { + config.contexts = config.contexts || new Set(); + config.contexts.add(bootstrap(defaultValue[0])); + } + } + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + if (!Array.isArray(data[key])) { + throw TypeError( + `The value for '${key}' property must be an array: ${typeof data[ + key + ]}`, + ); + } + model[key] = localConfig.create(data[key]); + } else { + model[key] = + (lastModel && lastModel[key]) || + (!localConfig.enumerable && + localConfig.create(defaultValue)) || + []; + } + }; + } + + const nestedConfig = bootstrap(defaultValue, true); + if (nestedConfig.enumerable || nestedConfig.external) { + return (model, data, lastModel) => { + let resultModel; + + if (hasOwnProperty.call(data, key)) { + const nestedData = data[key]; + + if (typeof nestedData !== "object" || nestedData === null) { + if (nestedData !== undefined && nestedData !== null) { + resultModel = { id: nestedData }; + } + } else { + const dataConfig = definitions.get(nestedData); + if (dataConfig) { + if (dataConfig.model !== defaultValue) { + throw TypeError( + "Model instance must match the definition", + ); + } + resultModel = nestedData; + } else { + resultModel = nestedConfig.create(nestedData); + sync(nestedConfig, resultModel.id, resultModel); + } + } + } else { + resultModel = lastModel && lastModel[key]; + } + + if (resultModel) { + const id = resultModel.id; + Object.defineProperty(model, key, { + get() { + return cache.get( + this, + key, + pending(this) ? _ : () => get(defaultValue, id), + ); + }, + enumerable: true, + }); + } else { + model[key] = undefined; + } + }; + } + + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + model[key] = nestedConfig.create( + data[key], + lastModel && lastModel[key], + ); + } else { + model[key] = lastModel + ? lastModel[key] + : nestedConfig.create({}); + } + }; + } + // eslint-disable-next-line no-fallthrough + default: { + const Constructor = getTypeConstructor(type, key); + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + model[key] = Constructor(data[key]); + } else if (lastModel && hasOwnProperty.call(lastModel, key)) { + model[key] = lastModel[key]; + } else { + model[key] = defaultValue; + } + }; + } + } + }); + + config.create = function create(data, lastModel) { + if (data === null) return null; + + if (typeof data !== "object") { + throw TypeError(`Model values must be an object instance: ${data}`); + } + + const model = transform.reduce((acc, fn) => { + fn(acc, data, lastModel); + return acc; + }, {}); + + definitions.set(model, config); + storePointer.set(model, store); + + return Object.freeze(model); + }; + + Object.freeze(placeholder); + + configs.set(Model, Object.freeze(config)); + } + + return config; +} + +const listPlaceholderPrototype = Object.getOwnPropertyNames( + Array.prototype, +).reduce((acc, key) => { + if (key === "length") return acc; + + Object.defineProperty(acc, key, { + get() { + throw Error( + `Model list instance in ${ + getModelState(this).state + } state - use store.pending(), store.error(), or store.ready() guards`, + ); + }, + }); + return acc; +}, []); + +const lists = new WeakMap(); +function setupListModel(Model, nested) { + let config = lists.get(Model); + + if (config && !config.enumerable) { + if (!nested && config.nested) { + throw TypeError( + stringifyModel( + Model, + "Nested model definition cannot be used outside of the parent definition", + ), + ); + } + } + + if (!config) { + const modelConfig = setupModel(Model); + + const contexts = new Set(); + contexts.add(modelConfig); + + if (!nested) { + if (!modelConfig.enumerable) { + throw TypeError( + stringifyModel( + Model, + "Provided model definition does not support listing (it must be enumerable - set `id` property to `true`)", + ), + ); + } + if (!modelConfig.storage.list) { + throw TypeError( + stringifyModel( + Model, + "Provided model definition storage does not support `list` action", + ), + ); + } + } + + config = { + list: true, + nested: !modelConfig.enumerable && nested, + model: Model, + contexts, + enumerable: modelConfig.enumerable, + storage: setupStorage({ + cache: modelConfig.storage.cache, + get: + !nested && + (id => { + return modelConfig.storage.list(id); + }), + }), + placeholder: () => Object.freeze(Object.create(listPlaceholderPrototype)), + isInstance: model => + Object.getPrototypeOf(model) !== listPlaceholderPrototype, + create(items) { + const result = items.reduce((acc, data) => { + let id = data; + if (typeof data === "object" && data !== null) { + id = data.id; + const dataConfig = definitions.get(data); + let model = data; + if (dataConfig) { + if (dataConfig.model !== Model) { + throw TypeError("Model instance must match the definition"); + } + } else { + model = modelConfig.create(data); + if (modelConfig.enumerable) { + id = model.id; + sync(modelConfig, id, model); + } + } + if (!modelConfig.enumerable) { + acc.push(model); + } + } else if (!modelConfig.enumerable) { + throw TypeError(`Model instance must be an object: ${typeof data}`); + } + if (modelConfig.enumerable) { + const key = acc.length; + Object.defineProperty(acc, key, { + get() { + return cache.get( + this, + key, + pending(this) ? _ : () => get(Model, id), + ); + }, + enumerable: true, + }); + } + return acc; + }, []); + + definitions.set(result, config); + storePointer.set(result, store); + + return Object.freeze(result); + }, + }; + + lists.set(Model, Object.freeze(config)); + } + + return config; +} + +function resolveTimestamp(h, v) { + return v || getCurrentTimestamp(); +} + +function stringifyId(id) { + switch (typeof id) { + case "object": + return JSON.stringify( + Object.keys(id) + .sort() + .reduce((acc, key) => { + if (typeof id[key] === "object" && id[key] !== null) { + throw TypeError( + `You must use primitive value for '${key}' key: ${typeof id[ + key + ]}`, + ); + } + acc[key] = id[key]; + return acc; + }, {}), + ); + case "undefined": + return undefined; + default: + return String(id); + } +} + +function mapError(model, err, suppressLog) { + /* istanbul ignore next */ + if (process.env.NODE_ENV !== "production" && suppressLog !== false) { + // eslint-disable-next-line no-console + console.error(err); + } + + return setModelState(model, "error", err); +} + +function get(Model, id) { + const config = bootstrap(Model); + let stringId; + + if (!config.storage.get) { + throw TypeError( + stringifyModel( + Model, + "Provided model definition does not support 'get' method", + ), + ); + } + + if (config.enumerable) { + stringId = stringifyId(id); + + if (!config.list && !stringId) { + throw TypeError( + stringifyModel( + Model, + `Provided model definition requires non-empty id: "${stringId}"`, + ), + ); + } + } else if (id !== undefined) { + throw TypeError( + stringifyModel(Model, "Provided model definition does not support id"), + ); + } + + return cache.get( + config, + stringId, + (h, cachedModel) => { + if (cachedModel && pending(cachedModel)) return cachedModel; + + let validContexts = true; + if (config.contexts) { + config.contexts.forEach(context => { + if ( + cache.get(context, context, resolveTimestamp) === + getCurrentTimestamp() + ) { + validContexts = false; + } + }); + } + + if ( + validContexts && + cachedModel && + (config.storage.cache === true || config.storage.validate(cachedModel)) + ) { + return cachedModel; + } + + try { + let result = config.storage.get(id); + + if (typeof result !== "object" || result === null) { + throw Error( + `Model instance ${ + stringId !== undefined ? `with '${stringId}' id` : "" + } does not exist: ${result}`, + ); + } + + if (result instanceof Promise) { + result = result + .then(data => { + if (typeof data !== "object" || data === null) { + throw Error( + `Model instance ${ + stringId !== undefined ? `with '${stringId}' id` : "" + } does not exist: ${result}`, + ); + } + + return sync( + config, + stringId, + config.create(stringId ? { ...data, id: stringId } : data), + ); + }) + .catch(e => { + return sync( + config, + stringId, + mapError(cachedModel || config.placeholder(stringId), e), + ); + }); + + return setModelState( + cachedModel || config.placeholder(stringId), + "pending", + result, + ); + } + + if (cachedModel) definitions.set(cachedModel, null); + return setTimestamp( + config.create(stringId ? { ...result, id: stringId } : result), + ); + } catch (e) { + return setTimestamp( + mapError(cachedModel || config.placeholder(stringId), e), + ); + } + }, + config.storage.validate, + ); +} + +const draftMap = new WeakMap(); + +function getValidationError(errors) { + const keys = Object.keys(errors); + const e = Error( + `Model validation failed (${keys.join( + ", ", + )}) - read the details from 'errors' property`, + ); + + e.errors = errors; + + return e; +} + +function set(model, values = {}) { + let config = definitions.get(model); + const isInstance = !!config; + + if (config === null) { + throw Error( + "Provided model instance has expired. Haven't you used stale value?", + ); + } + + if (!config) config = bootstrap(model); + + if (config.nested) { + throw stringifyModel( + config.model, + TypeError( + "Setting provided nested model instance is not supported, use the root model instance", + ), + ); + } + + if (config.list) { + throw TypeError("Listing model definition does not support 'set' method"); + } + + if (!config.storage.set) { + throw stringifyModel( + config.model, + TypeError( + "Provided model definition storage does not support 'set' method", + ), + ); + } + + if (isInstance && pending(model)) { + throw Error("Provided model instance is in pending state"); + } + + let id; + const setState = (state, value) => { + if (isInstance) { + setModelState(model, state, value); + } else { + const entry = cache.getEntry(config, id); + if (entry.value) { + setModelState(entry.value, state, value); + } + } + }; + + try { + if ( + config.enumerable && + !isInstance && + (!values || typeof values !== "object") + ) { + throw TypeError(`Values must be an object instance: ${values}`); + } + + if (values && hasOwnProperty.call(values, "id")) { + throw TypeError(`Values must not contain 'id' property: ${values.id}`); + } + + const localModel = config.create(values, isInstance ? model : undefined); + const keys = values ? Object.keys(values) : []; + const isDraft = draftMap.get(config); + const errors = {}; + const lastError = isInstance && isDraft && error(model); + + let hasErrors = false; + + if (localModel) { + config.checks.forEach((fn, key) => { + if (keys.indexOf(key) === -1) { + if (lastError && lastError.errors && lastError.errors[key]) { + hasErrors = true; + errors[key] = lastError.errors[key]; + } + + // eslint-disable-next-line eqeqeq + if (isDraft && localModel[key] == config.model[key]) { + return; + } + } + + let checkResult; + try { + checkResult = fn(localModel[key], key, localModel); + } catch (e) { + checkResult = e; + } + + if (checkResult !== true && checkResult !== undefined) { + hasErrors = true; + errors[key] = checkResult || true; + } + }); + + if (hasErrors && !isDraft) { + throw getValidationError(errors); + } + } + + id = localModel ? localModel.id : model.id; + + const result = Promise.resolve( + config.storage.set(isInstance ? id : undefined, localModel, keys), + ) + .then(data => { + const resultModel = + data === localModel ? localModel : config.create(data); + + if (isInstance && resultModel && id !== resultModel.id) { + throw TypeError( + `Local and storage data must have the same id: '${id}', '${resultModel.id}'`, + ); + } + + const resultId = resultModel ? resultModel.id : id; + + if (hasErrors && isDraft) { + setModelState(resultModel, "error", getValidationError(errors)); + } + + return sync( + config, + resultId, + resultModel || + mapError( + config.placeholder(resultId), + Error( + `Model instance ${ + id !== undefined ? `with '${id}' id` : "" + } does not exist: ${resultModel}`, + ), + false, + ), + true, + ); + }) + .catch(err => { + err = err !== undefined ? err : Error("Undefined error"); + setState("error", err); + throw err; + }); + + setState("pending", result); + + return result; + } catch (e) { + setState("error", e); + return Promise.reject(e); + } +} + +function clear(model, clearValue = true) { + if (typeof model !== "object" || model === null) { + throw TypeError( + `The first argument must be a model instance or a model definition: ${model}`, + ); + } + + const config = definitions.get(model); + + if (config === null) { + throw Error( + "Provided model instance has expired. Haven't you used stale value from the outer scope?", + ); + } + + if (config) { + cache.invalidate(config, model.id, clearValue, true); + } else { + if (!configs.get(model) && !lists.get(model[0])) { + throw Error( + "Model definition must be used before - passed argument is probably not a model definition", + ); + } + cache.invalidateAll(bootstrap(model), clearValue, true); + } +} + +function pending(model) { + if (model === null || typeof model !== "object") return false; + const { state, value } = getModelState(model); + return state === "pending" && value; +} + +function error(model, property) { + if (model === null || typeof model !== "object") return false; + const { state, value } = getModelState(model); + const result = state === "error" && value; + + if (result && property !== undefined) { + return result.errors && result.errors[property]; + } + + return result; +} + +function ready(model) { + if (model === null || typeof model !== "object") return false; + const config = definitions.get(model); + return !!(config && config.isInstance(model)); +} + +function mapValueWithState(lastValue, nextValue) { + const result = Object.freeze( + Object.keys(lastValue).reduce((acc, key) => { + Object.defineProperty(acc, key, { + get: () => lastValue[key], + enumerable: true, + }); + return acc; + }, Object.create(lastValue)), + ); + + definitions.set(result, definitions.get(lastValue)); + + const { state, value } = getModelState(nextValue); + return setModelState(result, state, value); +} + +function getValuesFromModel(model) { + const values = { ...model }; + delete values.id; + return values; +} + +function submit(draft) { + const config = definitions.get(draft); + if (!config || !draftMap.has(config)) { + throw TypeError(`Provided model instance is not a draft: ${draft}`); + } + + if (pending(draft)) { + throw Error("Model draft in pending state"); + } + + const options = draftMap.get(config); + let result; + + if (!options.id) { + result = store.set(options.model, getValuesFromModel(draft)); + } else { + const model = store.get(options.model, draft.id); + result = Promise.resolve(pending(model) || model).then(resolvedModel => + store.set(resolvedModel, getValuesFromModel(draft)), + ); + } + + result = result + .then(resultModel => { + setModelState(draft, "ready"); + return store + .set(draft, getValuesFromModel(resultModel)) + .then(() => resultModel); + }) + .catch(e => { + setModelState(draft, "error", e); + return Promise.reject(e); + }); + + setModelState(draft, "pending", result); + + return result; +} + +function required(value, key) { + return !!value || `${key} is required`; +} + +function valueWithValidation( + defaultValue, + validate = required, + errorMessage = "", +) { + switch (typeof defaultValue) { + case "string": + // eslint-disable-next-line no-new-wrappers + defaultValue = new String(defaultValue); + break; + case "number": + // eslint-disable-next-line no-new-wrappers + defaultValue = new Number(defaultValue); + break; + default: + throw TypeError( + `Default value must be a string or a number: ${typeof defaultValue}`, + ); + } + + let fn; + if (validate instanceof RegExp) { + fn = value => validate.test(value) || errorMessage; + } else if (typeof validate === "function") { + fn = (...args) => { + const result = validate(...args); + return result !== true && result !== undefined + ? result || errorMessage + : result; + }; + } else { + throw TypeError( + `The second argument must be a RegExp instance or a function: ${typeof validate}`, + ); + } + + validationMap.set(defaultValue, fn); + return defaultValue; +} + +function store(Model, options = {}) { + const config = bootstrap(Model); + + if (typeof options !== "object") { + options = { id: options }; + } + + if (options.id !== undefined && typeof options.id !== "function") { + const id = options.id; + options.id = host => host[id]; + } + + if (options.draft) { + if (config.list) { + throw TypeError( + "Draft mode is not supported for listing model definition", + ); + } + + Model = { + ...Model, + [store.connect]: { + get(id) { + const model = store.get(config.model, id); + return ready(model) ? model : pending(model); + }, + set(id, values) { + return values === null ? { id } : values; + }, + }, + }; + + options.draft = bootstrap(Model); + draftMap.set(options.draft, { model: config.model, id: options.id }); + } + + const createMode = options.draft && config.enumerable && !options.id; + + const desc = { + get: (host, lastValue) => { + if (createMode && !lastValue) { + const nextValue = options.draft.create({}); + sync(options.draft, nextValue.id, nextValue); + return store.get(Model, nextValue.id); + } + + const id = + options.draft && lastValue + ? lastValue.id + : options.id && options.id(host); + + const nextValue = store.get(Model, id); + + if (lastValue && nextValue !== lastValue && !ready(nextValue)) { + return mapValueWithState(lastValue, nextValue); + } + + return nextValue; + }, + set: config.list + ? undefined + : (host, values, lastValue) => { + if (!lastValue || !ready(lastValue)) lastValue = desc.get(host); + + store + .set(lastValue, values) + .catch(/* istanbul ignore next */ () => {}); + + return lastValue; + }, + connect: options.draft ? () => () => clear(Model, false) : undefined, + }; + + return desc; +} + +export default Object.assign(store, { + // storage + connect, + + // actions + get, + set, + clear, + + // guards + pending, + error, + ready, + + // helpers + submit, + value: valueWithValidation, +}); diff --git a/src/template/core.js b/src/template/core.js index 67a60cd8..c45bcd87 100644 --- a/src/template/core.js +++ b/src/template/core.js @@ -371,9 +371,9 @@ export function compileTemplate(rawParts, isSVG, styles) { !customElements.get(node.tagName.toLowerCase()) ) { throw Error( - `Missing '${stringifyElement( + `Missing ${stringifyElement( node, - )}' element definition in '${stringifyElement(host)}'`, + )} element definition in ${stringifyElement(host)}`, ); } } @@ -451,10 +451,9 @@ export function compileTemplate(rawParts, isSVG, styles) { if (process.env.NODE_ENV !== "production") { // eslint-disable-next-line no-console console.error( - `An error was thrown when updating a template expression:\n${beautifyTemplateLog( - signature, - index, - )}`, + `Following error was thrown when updating a template expression in ${stringifyElement( + host, + )}\n${beautifyTemplateLog(signature, index)}`, ); } throw error; diff --git a/src/template/helpers.js b/src/template/helpers.js index e9c4960d..0d71bb05 100644 --- a/src/template/helpers.js +++ b/src/template/helpers.js @@ -1,21 +1,83 @@ -const setCache = new Map(); -export function set(propertyName, value) { - if (!propertyName) - throw Error(`Target property name missing: ${propertyName}`); +import { storePointer } from "../utils.js"; + +function resolveValue({ target }, setter) { + let value; + + switch (target.type) { + case "radio": + case "checkbox": + value = target.checked && target.value; + break; + case "file": + value = target.files; + break; + default: + value = target.value; + } + + setter(value); +} + +function getPartialObject(name, value) { + return name + .split(".") + .reverse() + .reduce((acc, key) => { + if (!acc) return { [key]: value }; + return { [key]: acc }; + }, null); +} + +const stringCache = new Map(); + +export function set(property, valueOrPath) { + if (!property) { + throw Error( + `The first argument must be a property name or an object instance: ${property}`, + ); + } + + if (typeof property === "object") { + if (valueOrPath === undefined) { + throw Error( + "For model instance property the second argument must be defined", + ); + } + + const store = storePointer.get(property); + + if (!store) { + throw Error("Provided object must be a model instance of the store"); + } + + return (host, event) => { + resolveValue(event, value => { + store.set( + property, + valueOrPath !== null + ? getPartialObject(valueOrPath, value) + : valueOrPath, + ); + }); + }; + } if (arguments.length === 2) { return host => { - host[propertyName] = value; + host[property] = valueOrPath; }; } - let fn = setCache.get(propertyName); + let fn = stringCache.get(property); if (!fn) { - fn = (host, { target }) => { - host[propertyName] = target.value; + fn = (host, event) => { + resolveValue(event, value => { + host[property] = value; + }); }; - setCache.set(propertyName, fn); + + stringCache.set(property, fn); } return fn; diff --git a/src/utils.js b/src/utils.js index 1cdb13eb..9d028ccc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -35,3 +35,5 @@ export function stringifyElement(target) { export const IS_IE = "ActiveXObject" in window; export const deferred = Promise.resolve(); + +export const storePointer = new WeakMap(); diff --git a/test/spec/cache.js b/test/spec/cache.js index eaef55a5..6e3ce935 100644 --- a/test/spec/cache.js +++ b/test/spec/cache.js @@ -1,4 +1,11 @@ -import { get, set, invalidate, observe } from "../../src/cache.js"; +import { + get, + set, + getEntries, + invalidate, + invalidateAll, + observe, +} from "../../src/cache.js"; describe("cache:", () => { let target; @@ -52,6 +59,13 @@ describe("cache:", () => { expect(spy).not.toHaveBeenCalled(); }); + + it("forces getter to be called if validation fails", () => { + get(target, "key", () => get(target, "otherKey", () => "value")); + get(target, "key", spy, () => false); + + expect(spy).toHaveBeenCalledTimes(1); + }); }); describe("set()", () => { @@ -68,6 +82,18 @@ describe("cache:", () => { expect(spy).toHaveBeenCalledWith(target, "new value"); }); + it("does not invalidates state for next get call", () => { + get(target, "key", () => "value"); + get(target, "key", spy); + + expect(spy).toHaveBeenCalledTimes(0); + + set(target, "key", () => "value"); + get(target, "key", spy); + + expect(spy).toHaveBeenCalledTimes(0); + }); + it("invalidates dependant properties", () => { get(target, "key", () => get(target, "otherKey", () => "value")); set(target, "otherKey", () => "new value"); @@ -79,6 +105,23 @@ describe("cache:", () => { }); }); + describe("getEntries()", () => { + it("returns empty array for new object", () => { + expect(getEntries({})).toEqual([]); + }); + + it("returns an array with entries", () => { + const host = {}; + get(host, "key", () => "value"); + expect(getEntries(host)).toEqual([ + jasmine.objectContaining({ + value: "value", + key: "key", + }), + ]); + }); + }); + describe("invalidate()", () => { it("throws if called inside of the get()", () => { expect(() => @@ -106,6 +149,30 @@ describe("cache:", () => { }); }); + describe("invalidateAll()", () => { + it("throws if called inside of the get()", () => { + expect(() => get(target, "key", () => invalidateAll(target))).toThrow(); + }); + + it("does nothing if target has no entries", () => { + expect(() => invalidateAll({})).not.toThrow(); + }); + + it("clears all entries", () => { + get(target, "key", () => "value"); + + expect(getEntries(target).length).toBe(1); + + invalidateAll(target, true); + expect(getEntries(target)).toEqual([ + jasmine.objectContaining({ + value: undefined, + key: "key", + }), + ]); + }); + }); + describe("observe()", () => { const _ = (t, v) => v; diff --git a/test/spec/html.js b/test/spec/html.js index 90f7ef7c..4dbcd0fc 100644 --- a/test/spec/html.js +++ b/test/spec/html.js @@ -5,6 +5,7 @@ import renderFactory from "../../src/render.js"; import { dispatch, IS_IE } from "../../src/utils.js"; import { test, resolveTimeout, runInProd } from "../helpers.js"; import { property } from "../../src/index.js"; +import store from "../../src/store.js"; describe("html:", () => { let fragment; @@ -799,12 +800,12 @@ describe("html:", () => { let host; beforeEach(() => { - host = { firstName: "" }; + host = { value: "" }; }); - it('uses "value" from input', () => { + it("uses value property from text input", () => { const render = html` - + `; render(host, fragment); @@ -812,19 +813,143 @@ describe("html:", () => { input.value = "John"; dispatch(input, "input"); - expect(host.firstName).toBe("John"); + expect(host.value).toBe("John"); + }); + + it("uses value property from radio input", () => { + const render = html` + + + `; + + render(host, fragment); + fragment.children[0].click(); + expect(host.value).toBe("one"); + + fragment.children[1].click(); + expect(host.value).toBe("two"); + + dispatch(fragment.children[1], "change"); + expect(host.value).toBe("two"); + }); + + it("uses value property from checkbox input", () => { + const render = html` + + `; + + render(host, fragment); + fragment.children[0].click(); + expect(host.value).toBeTruthy(); + + fragment.children[0].click(); + expect(host.value).toBeFalsy(); + }); + + it("uses files property from file input", () => { + const render = html` + + `; + + render(host, fragment); + fragment.children[0].click(); + expect(host.value).toBeInstanceOf(FileList); + }); + + it("throws when set store model instance without property name", () => { + expect(() => { + html.set({}); + }).toThrow(); + }); + + it("throws when set object instance, which is not a valid store model instance", () => { + expect(() => { + html.set({}, "value"); + }).toThrow(); + }); + + it("set property of the store model instance", done => { + const model = store.get({ value: "test" }); + + const render = html` + + `; + + render(host, fragment); + + const input = fragment.children[0]; + input.value = "John"; + dispatch(input, "input"); + + store + .pending(model) + .then(nextModel => { + expect(nextModel).toEqual({ value: "John" }); + }) + .then(done); + }); + + it("set nested property of the store model instance", done => { + const model = store.get({ nested: { value: "test" } }); + + const render = html` + + `; + + render(host, fragment); + + const input = fragment.children[0]; + input.value = "John"; + dispatch(input, "input"); + + store + .pending(model) + .then(nextModel => { + expect(nextModel).toEqual({ nested: { value: "John" } }); + }) + .then(done); + }); + + it("reset store model instance by setting null", done => { + const model = store.get({ value: "test" }); + + store + .set(model, { value: "other" }) + .then(nextModel => { + const render = html` + + `; + + render(host, fragment); + fragment.children[0].click(); + + return store.pending(nextModel).then(finalModel => { + expect(finalModel).toEqual({ value: "test" }); + }); + }) + .then(done); }); it("set custom value", () => { const render = html` - + `; render(host, fragment); const input = fragment.children[0]; dispatch(input, "input"); - expect(host.firstName).toBe(undefined); + expect(host.value).toBe(undefined); }); it("saves callback in the cache", () => { diff --git a/test/spec/store.js b/test/spec/store.js new file mode 100644 index 00000000..b6715c04 --- /dev/null +++ b/test/spec/store.js @@ -0,0 +1,2054 @@ +import { store } from "../../src/index.js"; +import * as cache from "../../src/cache.js"; +import { resolveTimeout } from "../helpers.js"; + +describe("store:", () => { + let Model; + + beforeAll(() => { + window.env = "production"; + }); + + afterAll(() => { + window.env = "development"; + }); + + beforeEach(() => { + Model = { + id: true, + string: "value", + number: 1, + bool: false, + computed: ({ string }) => `This is the string: ${string}`, + nestedObject: { + value: "test", + }, + nestedExternalObject: { + id: true, + value: "test", + }, + nestedArrayOfPrimitives: ["one", "two"], + nestedArrayOfObjects: [{ one: "two" }], + nestedArrayOfExternalObjects: [{ id: true, value: "test" }], + }; + }); + + describe("get()", () => { + it("throws for wrong arguments", () => { + expect(() => store.get()).toThrow(); + }); + + it('throws for model definition with wrongly set "id" key', () => { + expect(() => store.get({ id: 1 })).toThrow(); + }); + + it("throws if property value is not a string, number or boolean", () => { + expect(() => store.get({ value: undefined })).toThrow(); + }); + + it("throws when called with parameters for singleton type", () => { + expect(() => store.get({}, "1")).toThrow(); + }); + + it("throws when called without parameters for enumerable definition", () => { + expect(() => store.get({ id: true })).toThrow(); + }); + + it("throws when property is set as null", () => { + expect(() => store.get({ value: null })).toThrow(); + }); + + it("throws when nested object is used as a primary model", () => { + store.get(Model, "1"); + expect(() => { + store.get(Model.nestedObject, "1"); + }).toThrow(); + }); + + it("throws when primary model is used as a nested object", () => { + Model = {}; + store.get(Model); + expect(() => { + store.get({ id: true, model: Model }, "1"); + }).toThrow(); + }); + + it("throws when nested array is used as a primary model", () => { + store.get(Model, "1"); + expect(() => store.get(Model.nestedArrayOfObjects)).toThrow(); + }); + + it("does not throw when nested array is used as other nested listing", () => { + store.get(Model, "1"); + expect(() => + store.get({ + nestedArrayOfObjects: Model.nestedArrayOfObjects, + }), + ).not.toThrow(); + }); + + it("returns a placeholder in error state for not defined model", () => { + const model = store.get({ id: true }, "1"); + expect(model).toBeInstanceOf(Object); + expect(store.error(model)).toBeInstanceOf(Error); + }); + + it("returns a placeholder in error state with guarded properties", () => { + const model = store.get({ id: true, testValue: "" }, 1); + + expect(model.id).toBe("1"); + expect(() => model.testValue).toThrow(); + expect(() => model.message).not.toThrow(); + }); + + it("returns a placeholder in error state for not found singleton model", () => { + Model = { + value: "test", + [store.connect]: { + get: () => {}, + set: () => {}, + }, + }; + + const model = store.get(Model); + expect(store.error(model)).toBeInstanceOf(Error); + }); + + describe("for singleton", () => { + beforeEach(() => { + Model = { + value: "test", + nested: { value: "test", other: { value: "test" } }, + }; + }); + + it("returns default model for singleton", () => { + expect(store.get(Model)).toEqual({ + value: "test", + nested: { value: "test", other: { value: "test" } }, + }); + }); + + it("reset values by setting null", () => { + const model = store.get(Model); + store + .set(model, { + value: "other value", + nested: { value: "other value", other: { value: "great" } }, + }) + .then(nextModel => { + expect(nextModel.value).toBe("other value"); + expect(nextModel.nested).toEqual({ + value: "other value", + other: { value: "great" }, + }); + store.set(nextModel, null).then(targetModel => { + expect(targetModel.value).toBe("test"); + expect(targetModel.nested).toEqual(Model.nested); + }); + }); + }); + }); + + describe("for created instance", () => { + let promise; + beforeEach(() => { + promise = store.set(Model, {}); + }); + + it("returns default values", done => + promise + .then(model => { + expect(model).toEqual({ + id: model.id, + string: "value", + number: 1, + bool: false, + nestedObject: { + value: "test", + }, + nestedExternalObject: undefined, + nestedArrayOfPrimitives: ["one", "two"], + nestedArrayOfObjects: [{ one: "two" }], + nestedArrayOfExternalObjects: [], + }); + expect(model.computed).toEqual("This is the string: value"); + }) + .then(done)); + + it("returns cached model", done => + promise.then(model => { + expect(store.get(Model, model.id)).toBe(model); + done(); + })); + }); + + describe("for listing models", () => { + let promise; + beforeEach(() => { + Model = { id: true, value: "" }; + promise = Promise.all([ + store.set(Model, { value: "one" }), + store.set(Model, { value: "two" }), + ]); + }); + + it("throws an error for singleton definition (without 'id' key)", () => { + expect(() => store.get([{}])).toThrow(); + }); + + it("throws an error for nested parameters", () => { + expect(() => + store.get([Model], { id: "", other: { value: "test" } }), + ).toThrow(); + }); + + it("returns a placeholder in error state when called with parameters", () => { + expect(store.error(store.get([Model], { a: "b" }))).toBeInstanceOf( + Error, + ); + }); + + it("returns a placeholder in error state with guarded properties", () => { + const model = store.get([Model], { a: "b" }); + + expect(() => model.map).toThrow(); + expect(() => model.message).not.toThrow(); + }); + + it("returns an array with updated models", done => { + expect(store.get([Model])).toEqual([]); + + promise + .then(() => { + expect(store.get([Model])).toEqual([ + jasmine.objectContaining({ value: "one" }), + jasmine.objectContaining({ value: "two" }), + ]); + }) + .then(done); + }); + + it("returns the same array", () => { + expect(store.get([Model])).toBe(store.get([Model])); + }); + + it("returns an array without deleted model", done => + promise + .then(([model]) => store.set(model, null)) + .then(() => { + const list = store.get([Model]); + expect(list).toEqual([jasmine.objectContaining({ value: "two" })]); + }) + .then(done)); + }); + }); + + describe("set()", () => { + let promise; + beforeEach(() => { + promise = store.set(Model); + }); + + it("throws when set method is not supported", () => { + expect(() => + store.set({ value: "", [store.connect]: { get: () => ({}) } }), + ).toThrow(); + }); + + it("throws when model has expired", done => { + promise + .then(model => + store.set(model, { string: "" }).then(() => { + expect(() => store.set(model, { string: "" })).toThrow(); + }), + ) + .then(done); + }); + + it("throws when list model definition is used", () => { + expect(() => + store.set([{ id: true, value: "" }], { value: "test" }), + ).toThrow(); + }); + + it("throws when list model instance is used", () => { + const model = store.get([{ id: true, value: "" }]); + expect(() => store.set(model, { value: "test" })).toThrow(); + }); + + it("throws when used on pending model instance", done => { + promise + .then(model => { + store.set(model, { string: "" }); + expect(() => store.set(model, { string: "" })).toThrow(); + }) + .then(done); + }); + + it("throws when updates a nested object directly", done => { + promise + .then(model => { + expect(() => { + store.set(model.nestedObject, {}); + }).toThrow(); + }) + .then(done); + }); + + it("rejects an error when values are not an object or null", done => + store + .set(Model, false) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done)); + + it("rejects an error when model definition is used with null", done => + store + .set(Model, null) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done)); + + it("rejects an error when model instance is used with not an object", done => + promise + .then(model => store.set(model, false)) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done)); + + it("rejects an error when values contain 'id' property", done => + promise + .then(model => store.set(model, model)) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done)); + + it("rejects an error when array with primitives is set with wrong type", done => { + promise + .then(model => { + store.set(model, { + nestedArrayOfPrimitives: "test", + }); + }) + .catch(e => { + expect(e).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("rejects an error when array with objects is set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfObjects: "test", + }), + ) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("rejects an error when array with external objects is set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfExternalObjects: "test", + }), + ) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("rejects an error when array with nested objects are set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfObjects: [{}, "test"], + }), + ) + .catch(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("returns a placeholder in error state for not found singleton model", done => { + Model = { + value: "test", + [store.connect]: { + get: () => {}, + set: (id, values) => values, + }, + }; + + store + .set(Model, null) + .then(model => { + expect(store.error(model)).toBeInstanceOf(Error); + }) + .then(done); + }); + + it('creates uuid for objects with "id" key', done => + store + .set(Model, { nestedArrayOfObjects: [{}] }) + .then(model => { + expect(model.id).toBeDefined(); + expect(model.nestedObject.id).not.toBeDefined(); + expect(model.nestedArrayOfObjects[0].id).not.toBeDefined(); + }) + .then(done)); + + it("updates single property", done => + promise + .then(model => + store.set(model, { string: "new value" }).then(newModel => { + expect(newModel.string).toBe("new value"); + expect(newModel.number).toBe(1); + expect(newModel.bool).toBe(false); + expect(newModel.nestedObject).toBe(model.nestedObject); + expect(newModel.nestedArrayOfObjects).toBe( + newModel.nestedArrayOfObjects, + ); + expect(newModel.nestedArrayOfPrimitives).toBe( + newModel.nestedArrayOfPrimitives, + ); + }), + ) + .then(done)); + + it("updates string value to empty string from null and undefined", done => { + Model = { + one: "one", + two: "two", + }; + + const model = store.get(Model); + store + .set(model, { one: null, two: undefined }) + .then(newModel => { + expect(newModel).toEqual({ one: "", two: "" }); + }) + .then(done); + }); + + it("updates nested object", done => + promise.then(model => + store + .set(model, { nestedObject: { value: "other" } }) + .then(newModel => { + expect(newModel.nestedObject).toEqual({ value: "other" }); + done(); + }), + )); + + it("rejects an error when updates nested object with different model", done => + promise.then(model => + store + .set({ test: "value" }) + .then(otherModel => + store.set(model, { nestedExternalObject: otherModel }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done), + )); + + it("updates nested external object with proper model", done => + promise.then(model => + store.set(Model.nestedExternalObject, {}).then(newExternal => + store + .set(model, { nestedExternalObject: newExternal }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(newExternal); + done(); + }), + ), + )); + + it("updates nested external object with data", done => + promise.then(model => + store + .set(model, { nestedExternalObject: { value: "one", a: "b" } }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toEqual({ + id: newModel.nestedExternalObject.id, + value: "one", + }); + done(); + }), + )); + + it("updates nested external object with model id", done => + promise.then(model => + store.set(Model.nestedExternalObject, {}).then(newExternal => + store + .set(model, { nestedExternalObject: newExternal.id }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(newExternal); + done(); + }), + ), + )); + + it("clears nested external object", done => + promise.then(model => + store + .set(model, { nestedExternalObject: null }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(undefined); + }) + .then(done), + )); + + it("updates nested array of primitives", done => + promise.then(model => + store + .set(model, { nestedArrayOfPrimitives: [1, 2, 3] }) + .then(newModel => { + expect(newModel.nestedArrayOfPrimitives).toEqual(["1", "2", "3"]); + done(); + }), + )); + + it("create model with nested array of objects", done => { + store + .set(Model, { + nestedArrayOfObjects: [{ one: "two" }, { two: "three", one: "four" }], + }) + .then(model => { + expect(model.nestedArrayOfObjects).toEqual([ + { one: "two" }, + { one: "four" }, + ]); + done(); + }); + }); + + it("updates nested array of objects", done => + promise.then(model => + store + .set(model, { nestedArrayOfObjects: [{ one: "three" }] }) + .then(newModel => { + expect(newModel.nestedArrayOfObjects).toEqual([{ one: "three" }]); + done(); + }), + )); + + it("rejects an error when model in nested array does not match model", done => { + store + .set({ myValue: "text" }) + .then(model => + store.set(Model, { + nestedArrayOfExternalObjects: [model], + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("creates model with nested external object from raw data", done => { + store + .set(Model, { + nestedArrayOfExternalObjects: [{ id: "1", value: "1" }], + }) + .then(model => { + expect(model.nestedArrayOfExternalObjects[0].id).toEqual("1"); + expect(model.nestedArrayOfExternalObjects).toEqual([ + { id: "1", value: "1" }, + ]); + done(); + }); + }); + + it("creates model with nested external object from model instance", done => { + store.set(Model.nestedArrayOfExternalObjects[0]).then(nestedModel => + store + .set(Model, { + nestedArrayOfExternalObjects: [nestedModel], + }) + .then(model => { + expect(model.nestedArrayOfExternalObjects[0]).toBe(nestedModel); + done(); + }), + ); + }); + + it("updates singleton model by the model definition reference", () => { + Model = { value: "test" }; + const model = store.get(Model); + + store.set(Model, { value: "new value" }); + store.pending(model).then(nextModel => { + expect(nextModel).toBe(store.get(Model)); + }); + }); + + it("deletes model", done => + promise.then(model => + store.set(model, null).then(() => { + const currentModel = store.get(Model, model.id); + expect(currentModel).toBeInstanceOf(Object); + expect(store.error(currentModel)).toBeInstanceOf(Error); + done(); + }), + )); + }); + + describe("clear()", () => { + let promise; + beforeEach(() => { + promise = store.set(Model, { string: "test" }); + }); + + it("throws when clear not a model instance or model definition", () => { + expect(() => store.clear()).toThrow(); + expect(() => store.clear("string")).toThrow(); + }); + + it("throws when first argument is error not connected to model instance", () => { + expect(() => store.clear(Error("Some error"))).toThrow(); + }); + + it("throws when model has expired", done => { + promise + .then(model => + store.set(model, { string: "other" }).then(() => { + expect(() => store.clear(model)).toThrow(); + }), + ) + .then(done); + }); + + it("removes model instance by reference", done => { + promise + .then(model => { + store.clear(model); + expect(store.error(store.get(Model, model.id))).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("removes model instance by id", done => { + promise + .then(model => { + store.clear(Model, model.id); + expect(store.error(store.get(Model, model.id))).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("removes all model instances by definition", done => { + promise + .then(model => { + store.clear(Model); + expect(store.error(store.get(Model, model.id))).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("only invalidates with clearValue option set to false", done => { + promise.then(model => { + const spy = jasmine.createSpy(); + const unobserve = cache.observe( + {}, + "key", + () => { + spy(); + return store.get(Model, model.id); + }, + () => {}, + ); + + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + store.clear(model, false); + + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(2); + expect(store.get(Model, model.id)).toBe(model); + + unobserve(); + done(); + }); + }); + }); + }); + }); + + describe("value()", () => { + it("throws when value is not a string or a number", () => { + expect(() => store.value(null)).toThrow(); + }); + + it("throws when validate function has wrong type", () => { + expect(() => store.value("", null)).toThrow(); + }); + + it("throws when string instance is directly used", () => { + // eslint-disable-next-line no-new-wrappers + Model = { some: new String("") }; + expect(() => store.get(Model)).toThrow(); + }); + + it("requires not empty string for new model", done => { + Model = { id: true, value: store.value("test") }; + + store + .set(Model, { value: "" }) + .catch(e => { + expect(e.errors.value).toBeDefined(); + }) + .then(done); + }); + + it("requires not empty string for updated model", done => { + Model = { id: true, value: store.value("test") }; + + store + .set(Model, {}) + .then(model => { + return store.set(model, { value: "" }).catch(e => { + expect(e.errors.value).toBeDefined(); + expect(store.error(model)).toBe(e); + }); + }) + .then(done); + }); + + it("requires non-zero value for new model", done => { + Model = { id: true, value: store.value(100) }; + + store + .set(Model, { value: 0 }) + .catch(e => { + expect(e.errors.value).toBeDefined(); + }) + .then(done); + }); + + it("requires non-zero value for updated model", done => { + Model = { id: true, value: store.value(100) }; + + store + .set(Model, {}) + .then(model => { + return store.set(model, { value: 0 }).catch(e => { + expect(e.errors.value).toBeDefined(); + expect(store.error(model)).toBe(e); + }); + }) + .then(done); + }); + + it("uses custom validation function", done => { + Model = { + id: true, + value: store.value("", v => v !== "test", "custom message"), + }; + + store + .set(Model, { value: "test" }) + .catch(e => { + expect(e.errors.value).toBe("custom message"); + }) + .then(done); + }); + + it("uses a regexp as a validation function", done => { + Model = { + id: true, + value: store.value("", /[a-z]+/, "custom message"), + }; + + store + .set(Model, { value: "123" }) + .catch(e => { + expect(e.errors.value).toBe("custom message"); + }) + .then(done); + }); + + it("allows throwing an error in validation function", done => { + Model = { + id: true, + value: store.value("", v => { + if (v === "test") throw Error("Some error"); + }), + }; + + store + .set(Model, { value: "test" }) + .catch(e => { + expect(e.errors.value).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("allows returning false value in validation function", done => { + Model = { + id: true, + value: store.value("", v => { + if (v === "test") return false; + return true; + }), + }; + + store + .set(Model, { value: "test" }) + .catch(e => { + expect(e.errors.value).toBe(true); + }) + .then(done); + }); + + it("for a draft it aggregates errors when updating properties one by one", done => { + Model = { + id: true, + one: store.value("one"), + two: store.value("two"), + }; + + const desc = store(Model, { draft: true }); + const host = {}; + const model = desc.get(host); + + desc.set(host, { one: "" }, model); + + store + .pending(model) + .then(nextModel => { + const error = store.error(nextModel); + expect(error).toBeDefined(); + expect(error.errors.one).toBeDefined(); + + desc.set(host, { two: "" }, nextModel); + return store.pending(nextModel); + }) + .then(nextModel => { + const error = store.error(nextModel); + expect(error).toBeDefined(); + expect(error.errors.one).toBeDefined(); + expect(error.errors.two).toBeDefined(); + }) + .then(done); + }); + + it("for a draft it allows default value, which does not pass validation", done => { + Model = { id: true, value: store.value(""), number: store.value(100) }; + + const desc = store(Model, { draft: true }); + const host = {}; + const model = desc.get(host); + + desc.set(host, { number: 0 }, model); + store + .pending(model) + .then(nextModel => { + const error = store.error(nextModel); + expect(error.errors.value).not.toBeDefined(); + }) + .then(done); + }); + }); + + describe("guards", () => { + it("returns false if value is not an object instance", () => { + expect(store.pending(null)).toBe(false); + expect(store.error(null)).toBe(false); + expect(store.ready(null)).toBe(false); + expect(store.pending()).toBe(false); + expect(store.error()).toBe(false); + expect(store.ready()).toBe(false); + }); + + it("ready() returns truth for ready model instance", done => { + Model = { id: true }; + store + .set(Model) + .then(model => { + expect(store.ready(model)).toBe(true); + }) + .then(done); + }); + + it("ready() returns truth for ready a list of models", () => { + Model = { id: true }; + const list = store.get([Model]); + expect(store.ready(list)).toBe(true); + }); + + it("error() returns validation message", done => { + Model = { id: true, value: store.value("test") }; + + store + .set(Model, {}) + .then(model => { + store.set(model, { value: "" }); + expect(store.error(model, "value")).toBe("value is required"); + }) + .then(done); + }); + }); + + describe("factory", () => { + describe("for enumerable model", () => { + let promise; + let desc; + + beforeEach(() => { + promise = store.set(Model, {}); + desc = store(Model, ({ id }) => id); + }); + + it("get store model instance", done => { + promise + .then(model => { + expect(desc.get(model)).toBe(model); + }) + .then(done); + }); + + it("throws when setting not initialized model", () => { + expect(() => desc.set({ id: 1 })).toThrow(); + }); + + it("maps id to host property", done => { + desc = store(Model, "id"); + promise + .then(model => { + expect(desc.get({ id: model.id })).toBe(model); + }) + .then(done); + }); + + it("updates store model instance", done => { + promise + .then(model => { + expect(desc.set(model, { string: "new value" })).toBe(model); + expect(store.pending(model)).toBeInstanceOf(Promise); + return model; + }) + .then(model => { + const nextModel = desc.get(model, model); + expect(nextModel).not.toBe(model); + expect(nextModel.string).toBe("new value"); + }) + .then(done); + }); + + it("removes store model instance", done => { + promise + .then(model => { + expect(desc.set(model, null, model)).toBe(model); + return model; + }) + .then(model => { + const nextModel = desc.get(model, model); + expect(store.error(nextModel)).toBeInstanceOf(Error); + expect(store.ready(nextModel)).toBe(false); + }) + .then(done); + }); + + describe("in draft mode", () => { + beforeEach(() => { + desc = store(Model, { id: "id", draft: true }); + }); + + it("returns a draft of the original model", done => { + promise + .then(model => { + const draftModel = desc.get(model); + + expect(desc.get(model)).not.toBe(model); + expect(draftModel.id).toBe(model.id); + }) + .then(done); + }); + + it("returns the first draft of the original model after model changes", done => { + promise + .then(model => { + const draftModel = desc.get(model); + + return store + .set(model, { string: "new value" }) + .then(targetModel => + resolveTimeout(() => { + const nextDraftModel = desc.get(model, draftModel); + + expect(targetModel.string).toBe("new value"); + expect(nextDraftModel.string).toBe("value"); + expect(nextDraftModel).toBe(draftModel); + }), + ); + }) + .then(done); + }); + + it("updates a draft of the original model", done => { + promise + .then(model => { + const draftModel = desc.get(model); + desc.set(model, { string: "new value" }, draftModel); + + return store.pending(draftModel).then(nextDraftModel => { + expect(desc.get(model, draftModel)).toBe(nextDraftModel); + expect(nextDraftModel.string).toBe("new value"); + const targetModel = store.get(Model, model.id); + + expect(targetModel).not.toEqual(nextDraftModel); + }); + }) + .then(done); + }); + + it("throws when submit not a draft", done => { + promise + .then(model => { + expect(() => store.submit(model)).toThrow(); + }) + .then(done); + }); + + it("submits changes from draft to the original model", done => { + promise + .then(model => { + const draftModel = desc.get(model); + desc.set(model, { string: "new value" }, draftModel); + + return store.pending(draftModel).then(nextDraftModel => { + const result = store.submit(nextDraftModel); + expect(store.pending(nextDraftModel)).toBe(result); + + return result.then(targetModel => { + expect(store.get(Model, nextDraftModel.id)).toBe(targetModel); + expect(targetModel).toEqual(nextDraftModel); + expect(desc.get(model, nextDraftModel)).toEqual(targetModel); + }); + }); + }) + .then(done); + }); + + it("clears draft cache when disconnected", done => { + promise + .then(model => { + desc.get(model); + desc.connect(model)(); + + return resolveTimeout(() => { + store.set(model, { string: "new value" }).then(() => { + const nextDraftModel = desc.get(model); + expect(nextDraftModel.string).toBe("new value"); + }); + }); + }) + .then(done); + }); + }); + }); + + describe("for enumerable model in draft mode without id", () => { + let desc; + + beforeEach(() => { + desc = store(Model, { draft: true }); + }); + + it("throws an error when draft mode is off", () => { + desc = store(Model); + expect(() => desc.get({})).toThrow(); + }); + + it("returns new model instance for not initialized model", () => { + const draftModel = desc.get({}); + expect(draftModel).toBeDefined(); + expect(store.ready(draftModel)).toBe(true); + + expect(desc.get({}, draftModel)).toBe(draftModel); + }); + + it("updates not initialized draft new model instance", done => { + const draftModel = desc.set({}, { string: "new value" }); + + store + .pending(draftModel) + .then(targetModel => { + const nextDraftModel = desc.get({}, draftModel); + expect(targetModel).toBe(nextDraftModel); + expect(nextDraftModel.string).toBe("new value"); + }) + .then(done); + }); + + it("throws when setting draft model instance in pending state", () => { + const pendingModel = desc.set({}, { string: "my value" }); + expect(() => + desc.set({}, { string: "my value" }, pendingModel), + ).toThrow(); + }); + + it("throws when submits draft model instance in pending state", () => { + const pendingModel = desc.set({}, { string: "my value" }); + expect(() => store.submit(pendingModel)).toThrow(); + }); + + it("submits new model each time", done => { + const draftModel = desc.set({}, { string: "new value" }); + + store + .pending(draftModel) + .then(nextDraftModel => { + return store.submit(nextDraftModel).then(targetModel => { + expect(store.get(Model, targetModel.id)).toBe(targetModel); + expect(targetModel.id).not.toBe(nextDraftModel.id); + expect(targetModel.string).toBe(nextDraftModel.string); + + const targetDraftModel = desc.get({}, nextDraftModel); + expect(targetDraftModel).not.toBe(nextDraftModel); + + return store.submit(targetDraftModel).then(nextTargetModel => { + expect(nextTargetModel.id).not.toBe(targetModel); + }); + }); + }) + .then(done); + }); + + it("reset draft values", done => { + const draftModel = desc.set({}, { string: "new value" }); + store + .pending(draftModel) + .then(() => { + const nextDraftModel = desc.get({}, draftModel); + desc.set({}, null, nextDraftModel); + + return store.pending(nextDraftModel).then(() => { + const targetDraftModel = desc.get({}, nextDraftModel); + expect(targetDraftModel.string).toBe("value"); + }); + }) + .then(done); + }); + }); + + describe("for singleton model", () => { + let desc; + + beforeEach(() => { + Model = { value: "test" }; + desc = store(Model); + }); + + it("throws when setting model is in pending state", () => { + Model = { + value: "test", + [store.connect]: () => Promise.resolve({}), + }; + + desc = store(Model); + + const pendingModel = desc.get({}); + expect(() => desc.set({}, {}, pendingModel)).toThrow(); + }); + + it("returns model instance", () => { + expect(desc.get({})).toEqual({ value: "test" }); + }); + + it("set model", () => { + desc.set({}, { value: "new value" }); + const pendingModel = desc.get({}); + store.pending(pendingModel).then(() => { + expect(desc.get({}, pendingModel)).toEqual({ value: "new value" }); + }); + }); + + it("set model from the error state", done => { + Model = { + value: "test", + [store.connect]: { + get: () => Promise.reject(Error()), + set: (id, values) => Promise.resolve(values), + }, + }; + + desc = store(Model); + const pendingModel = desc.get({}); + store + .pending(pendingModel) + .then(errorModel => { + desc.set({}, { value: "one" }, errorModel); + return store.pending(errorModel); + }) + .then(model => { + expect(model.value).toBe("one"); + }) + .then(done); + }); + + it("updates singleton model", () => { + const model = desc.get({}); + desc.set({}, { value: "new value" }, model); + store.pending(model).then(() => { + expect(desc.get({}, model)).toEqual({ value: "new value" }); + }); + }); + }); + + describe("for listing model", () => { + it("does not have set method", () => { + expect(store([Model]).set).toBe(undefined); + }); + + it("throws for the draft mode", () => { + expect(() => store([Model], { draft: true })).toThrow(); + }); + }); + + describe("connected to async storage", () => { + let desc; + let mode; + let host; + + beforeEach(() => { + const storage = { + 1: { value: "one" }, + 2: { value: "two" }, + }; + Model = { + id: true, + value: "test", + [store.connect]: { + get: id => Promise[mode]().then(() => storage[id]), + set: (id, values) => Promise[mode]().then(() => values), + }, + }; + desc = store(Model, ({ id }) => id); + mode = "resolve"; + host = { id: "1" }; + }); + + it("returns pending placeholder with prototype to model", done => { + const pendingModel = desc.get(host); + store + .pending(pendingModel) + .then(() => { + const modelOne = desc.get(host, pendingModel); + expect(modelOne).toEqual({ id: "1", value: "one" }); + + host.id = "2"; + + const pendingTwo = desc.get(host, modelOne); + const promise = store.pending(pendingTwo); + + expect(pendingTwo).toEqual(modelOne); + expect(pendingTwo).not.toBe(modelOne); + expect(store.ready(pendingTwo)).toBe(true); + + expect(promise).toBeInstanceOf(Promise); + + return promise.then(modelTwo => { + const result = desc.get(host, pendingTwo); + expect(store.ready(modelTwo)).toBe(true); + expect(result).toBe(modelTwo); + }); + }) + .then(done); + }); + + describe("for draft mode", () => { + beforeEach(() => { + desc = store(Model, { id: "id", draft: true }); + }); + + it("returns a draft of the original model", done => { + const draftModel = desc.get(host); + expect(store.pending(draftModel)).toBeInstanceOf(Promise); + + store + .pending(draftModel) + .then(() => { + expect(desc.get(host, draftModel).value).toBe("one"); + }) + .then(done); + }); + + it("always returns the first draft of the original model", done => { + const draftModel = desc.get(host); + expect(store.pending(draftModel)).toBeInstanceOf(Promise); + + store + .pending(store.get(Model, "1")) + .then(model => { + return store.set(model, { value: "other" }); + }) + .then(() => { + expect(desc.get(host, draftModel).value).toBe("one"); + }) + .then(done); + }); + + it("sets error state when submit fails", done => { + store + .pending(desc.get(host)) + .then(draftModel => { + mode = "reject"; + return store.submit(draftModel).catch(error => { + expect(store.error(draftModel)).toBe(error); + }); + }) + .then(done); + }); + }); + }); + }); + + describe("connected to sync storage", () => { + let storage; + let maxId; + beforeEach(() => { + maxId = 2; + storage = { + 1: { id: "1", value: "test" }, + 2: { id: "2", value: "other" }, + }; + + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + if (!id) { + maxId += 1; + const result = { ...values, id: maxId }; + storage[id] = result; + return result; + } + + if (values) { + storage[id || values.id] = values; + return values; + } + + delete storage[id]; + return null; + }, + list: () => Object.values(storage), + }, + }; + }); + + it("throws an error when get method is not defined", () => { + Model = { id: true, [store.connect]: {} }; + expect(() => store.get(Model, "1")).toThrow(); + }); + + it("throws an error for listing model when list method is not defined", () => { + Model = { id: true, [store.connect]: { get: () => {} } }; + expect(() => store.get([Model])).toThrow(); + }); + + it("throws when cache is set with wrong type", () => { + expect(() => + store.get({ value: "test", [store.connect]: { cache: "lifetime" } }), + ).toThrow(); + }); + + it("rejects an error when id does not match", done => { + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + return { ...values, id: parseInt(id, 10) + 1 }; + }, + }, + }; + + const model = store.get(Model, 1); + store + .set(model, { value: "test" }) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("returns a placeholder in error state when get action throws", () => { + storage = null; + const model = store.get(Model, 1); + expect(store.error(model)).toBeInstanceOf(Error); + expect(store.get(Model, 1)).toBe(model); + }); + + it("does not cache set action when it rejects an error", done => { + const origStorage = storage; + storage = null; + store + .set(Model, { value: "other" }) + .catch(() => { + storage = origStorage; + expect(store.get(Model, 1)).toEqual({ id: "1", value: "test" }); + }) + .then(done); + }); + + it("returns a promise rejecting an error instance when set throws", done => { + storage = null; + store + .set(Model, { value: "test" }) + .catch(e => { + expect(e).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("returns a placeholder in error state when get throws primitive value", () => { + Model = { + id: true, + [store.connect]: () => { + throw Promise.resolve(); + }, + }; + expect(store.error(store.get(Model, 1))).toBeInstanceOf(Promise); + }); + + it("returns an error for not existing model", () => { + expect(store.error(store.get(Model, 0))).toBeInstanceOf(Error); + }); + + it("returns model from the storage", () => { + expect(store.get(Model, 1)).toEqual({ id: "1", value: "test" }); + }); + + it("returns the same model for string or number id", () => { + expect(store.get(Model, "1")).toBe(store.get(Model, 1)); + }); + + it("returns a list of models", () => { + expect(store.get([Model])).toEqual([ + { id: "1", value: "test" }, + { id: "2", value: "other" }, + ]); + }); + + it("adds item to list of models", done => { + expect(store.get([Model]).length).toBe(2); + store + .set(Model, { value: "new value" }) + .then(() => { + const list = store.get([Model]); + expect(list.length).toBe(3); + expect(list[2]).toEqual({ id: "3", value: "new value" }); + }) + .then(done); + }); + + it("removes item form list of models", done => { + store + .set(store.get([Model])[0], null) + .then(() => { + const list = store.get([Model]); + expect(list.length).toBe(1); + }) + .then(done); + }); + + it("returns new list when modifies already existing item", done => { + const list = store.get([Model]); + store + .set(list[0], { value: "new value" }) + .then(() => { + const newList = store.get([Model]); + expect(newList).not.toBe(list); + }) + .then(done); + }); + + it("calls observed properties once", done => { + const spy = jasmine.createSpy("observe callback"); + const getter = () => store.get([Model]); + const unobserve = cache.observe({}, "key", getter, spy); + + resolveTimeout(() => { + expect(spy).toHaveBeenCalledTimes(1); + unobserve(); + }).then(done); + }); + + it("set states for model instance", () => { + const model = store.get(Model, 1); + expect(store.pending(model)).toBe(false); + expect(store.ready(model)).toBe(true); + expect(store.error(model)).toBe(false); + }); + + it("for cache set to 'false' calls storage each time", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => storage[id], + }, + }; + + const model = store.get(Model, 1); + expect(model).toEqual({ id: "1", value: "test" }); + + expect(model).toBe(store.get(Model, 1)); + expect(model).toBe(store.get(Model, 1)); + + resolveTimeout(() => { + expect(model).not.toBe(store.get(Model, 1)); + }).then(done); + }); + + it("for cache set to 'false' does not call get for single item", done => { + const spy = jasmine.createSpy("get"); + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => { + spy(id); + return storage[id]; + }, + list: () => Object.values(storage), + }, + }; + + const model = store.get([Model]); + requestAnimationFrame(() => { + expect(model[0]).toEqual({ id: "1", value: "test" }); + expect(spy).toHaveBeenCalledTimes(0); + done(); + }); + }); + + it("for cache set to number get calls storage after timeout", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: 100, + get: id => storage[id], + }, + }; + + const model = store.get(Model, 1); + expect(model).toEqual({ id: "1", value: "test" }); + expect(model).toBe(store.get(Model, 1)); + + resolveTimeout(() => { + expect(model).not.toBe(store.get(Model, 1)); + }).then(done); + }); + + it("uses id returned from set action", done => { + let count = 2; + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + if (!id) { + id = count + 1; + count += 1; + values = { id, ...values }; + } + storage[id] = values; + return values; + }, + }, + }; + + store + .set(Model, { value: "test" }) + .then(model => { + expect(store.get(Model, model.id)).toBe(model); + }) + .then(done); + }); + + it("clear forces call for model again", done => { + const model = store.get(Model, 1); + store.clear(model); + requestAnimationFrame(() => { + expect(store.get(Model, 1)).not.toBe(model); + done(); + }); + }); + + describe("with nested array options", () => { + const setupDep = options => { + return { + items: [Model, options], + [store.connect]: () => ({ items: Object.values(storage) }), + }; + }; + + it("throws an error when options are set with wrong type", () => { + expect(() => store.get({ items: [Model, true] })).toThrow(); + }); + + it("returns updated list when loose option is set", done => { + const DepModel = setupDep({ loose: true }); + store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(list.items[0], null) + .then(() => { + const newList = store.get(DepModel); + expect(newList.items.length).toBe(1); + }) + .then(done); + }); + + it("returns the same list if loose options are not set", done => { + const DepModel = setupDep(); + store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(list.items[0], null) + .then(() => { + const newList = store.get(DepModel); + expect(store.error(newList.items[0])).toBeInstanceOf(Error); + expect(newList.items.length).toBe(2); + }) + .then(done); + }); + + it("returns the same list if loose options are not set", done => { + const DepModel = setupDep({ loose: false }); + store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(list.items[0], null) + .then(() => { + const newList = store.get(DepModel); + expect(store.error(newList.items[0])).toBeInstanceOf(Error); + expect(newList.items.length).toBe(2); + }) + .then(done); + }); + + it("returns updated list if one of many loose arrays changes", done => { + const otherStorage = { + "1": { id: "1", value: "test" }, + }; + const NewModel = { + id: true, + value: "", + [store.connect]: { + get: id => otherStorage[id], + set: (id, values) => { + if (values === null) { + delete otherStorage[id]; + return null; + } + + otherStorage[id] = values; + return values; + }, + }, + }; + + const DepModel = { + items: [Model, { loose: true }], + otherItems: [NewModel, { loose: true }], + [store.connect]: () => ({ + items: Object.values(storage), + otherItems: Object.values(otherStorage), + }), + }; + + const list = store.get(DepModel); + store.set(list.otherItems[0], null); + + requestAnimationFrame(() => { + const newList = store.get(DepModel); + expect(newList.otherItems.length).toBe(0); + done(); + }); + }); + }); + + it("set action receives list of updated keys", done => { + const spy = jasmine.createSpy(); + Model = { + one: "one", + two: "two", + [store.connect]: { + get: () => {}, + set: (...args) => { + spy(...args); + return args[1]; + }, + }, + }; + + store + .set(Model, { two: "other" }) + .then(() => { + expect(spy).toHaveBeenCalled(); + + const args = spy.calls.first().args; + expect(args.length).toBe(3); + expect(args[2]).toEqual(["two"]); + }) + .then(done); + }); + }); + + describe("connected to async storage -", () => { + let fn; + beforeEach(() => { + fn = id => Promise.resolve({ id, value: "true" }); + Model = { + id: true, + value: "", + [store.connect]: id => fn(id), + }; + }); + + it("rejects an error when promise resolves with other type than object", done => { + fn = () => { + return Promise.resolve("value"); + }; + + store.get(Model, 1); + + Promise.resolve() + .then(() => {}) + .then(() => { + const model = store.get(Model, 1); + expect(store.error(model)).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("returns a placeholder in pending state", () => { + const placeholder = store.get(Model, 1); + expect(placeholder).toBeInstanceOf(Object); + expect(() => placeholder.value).toThrow(); + }); + + it("returns a placeholder in error state for not found singleton model", done => { + Model = { + value: "test", + [store.connect]: { + get: () => Promise.resolve(), + set: (id, values) => Promise.resolve(values), + }, + }; + + const pendingModel = store.get(Model); + store + .pending(pendingModel) + .then(model => { + expect(store.error(model)).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("calls storage get action once for permanent cache", () => { + const spy = jasmine.createSpy(); + fn = id => { + spy(id); + return Promise.resolve({ id, value: "test" }); + }; + store.get(Model, 1); + store.get(Model, 1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("calls storage get action once for time-based cache", () => { + const spy = jasmine.createSpy(); + Model = { + id: true, + value: "", + [store.connect]: { + cache: 100, + get: id => { + spy(id); + return Promise.resolve({ id, value: "test" }); + }, + }, + }; + + store.get(Model, 1); + store.get(Model, 1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("calls observe method twice (pending & ready states)", done => { + const spy = jasmine.createSpy(); + cache.observe({}, "key", () => store.get(Model, "1"), spy); + + resolveTimeout(() => { + expect(spy).toHaveBeenCalledTimes(2); + }).then(done); + }); + + it("returns cached external nested object in pending state", done => { + Model = { + id: true, + value: "", + nestedExternalObject: { + id: true, + value: "test", + [store.connect]: { + cache: false, + get: id => Promise.resolve({ id, value: "one" }), + }, + }, + [store.connect]: { + cache: false, + get: id => + Promise.resolve({ + id, + value: "test", + nestedExternalObject: "1", + }), + set: (id, values) => Promise.resolve(values), + }, + }; + + store + .pending(store.get(Model, 1)) + .then(() => { + const model = store.get(Model, 1); + const nestedModel = model.nestedExternalObject; + + return store.pending(nestedModel).then(() => { + const resolvedNestedModel = model.nestedExternalObject; + expect(resolvedNestedModel).not.toBe(nestedModel); + + store.set(model, { value: "test 2" }); + expect(model.nestedExternalObject).toBe(resolvedNestedModel); + }); + }) + .then(done); + }); + + it("returns cached item of list in pending state", () => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => + Promise.resolve({ + id, + value: "test", + }), + set: (id, values) => Promise.resolve(values), + list: () => Promise.resolve(["1"]), + }, + }; + + store + .pending(store.get([Model])) + .then(models => { + return store.pending(models[0]).then(() => models); + }) + .then(models => { + const model = models[0]; + store.set(model, { value: "new value" }); + + Promise.resolve().then(() => { + const nextModels = store.get([Model]); + expect(nextModels[0]).toBe(model); + }); + }); + }); + + it("returns the same list after timestamp changes", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: 1000 * 30, + get: () => {}, + list: () => Promise.resolve([{ id: "1", value: "test" }]), + }, + }; + + store.pending(store.get([Model])).then(resolvedModel => { + setTimeout(() => { + expect(store.get([Model])).toBe(resolvedModel); + done(); + }, 100); + }); + }); + + it("returns placeholder in async calls for long fetching model", done => { + let resolvePromise; + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => + new Promise(resolve => { + resolvePromise = () => resolve({ id, value: "test" }); + }), + }, + }; + + const pendingModel = store.get(Model, 1); + expect(store.pending(pendingModel)).toBeInstanceOf(Promise); + expect(() => pendingModel.value).toThrow(); + + let resolvedModel; + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBeInstanceOf(Promise); + + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBeInstanceOf(Promise); + + resolvePromise(); + Promise.resolve().then(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBe(false); + + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBeInstanceOf(Promise); + done(); + }); + }); + }); + }); + }); + + describe("for success", () => { + it("sets pending state", done => { + expect(store.pending(store.get(Model, 1))).toBeInstanceOf(Promise); + + Promise.resolve() + .then(() => { + expect(store.pending(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets ready state", done => { + expect(store.ready(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => { + expect(store.ready(store.get(Model, 1))).toBe(true); + }) + .then(done); + }); + + it("sets error state", done => { + expect(store.error(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => { + expect(store.error(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + }); + + describe("for error", () => { + beforeEach(() => { + fn = () => Promise.reject(Error("some error")); + }); + + it("caches an error result", done => { + store.get(Model, 1); + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.get(Model, 1)).toBe(store.get(Model, 1)); + }) + .then(done); + }); + + it("sets pending state", done => { + expect(store.pending(store.get(Model, 1))).toBeInstanceOf(Promise); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.pending(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets ready state", done => { + expect(store.ready(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.ready(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets error state", done => { + expect(store.error(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.error(store.get(Model, 1))).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("sets pending state for singleton", done => { + Model = { + value: "test", + [store.connect]: { + get: () => Promise.reject(Error("some error")), + set: () => Promise.reject(Error("some error")), + }, + }; + + store.get(Model); + + Promise.resolve() + .then(() => {}) + .then(() => { + const model = store.get(Model); + expect(store.error(model)).toBeInstanceOf(Error); + + store.set(Model, { value: "other" }).catch(() => {}); + const nextModel = store.get(Model); + expect(store.pending(nextModel)).toBeInstanceOf(Promise); + return Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.pending(nextModel)).toBe(false); + }); + }) + .then(done); + }); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index ef4c5a07..8d88fbf6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -56,7 +56,69 @@ declare namespace hybrids { function children(hybridsOrFn: (Hybrids | ((hybrids: Hybrids) => boolean)), options? : { deep?: boolean, nested?: boolean }): Descriptor; function render(fn: RenderFunction, customOptions?: { shadowRoot?: boolean | object }): Descriptor; + /* Store */ + + type Model = { + [property in keyof Omit]: M[property] | ((model: M) => any); + } & { + id?: true; + __store__connect__?: Storage; + }; + + type ModelIdentifier = + | string + | undefined + | { + [property: string]: + | string + | boolean + | number + | null; + }; + + type ModelValues = { + [property in keyof M]?: M[property]; + } + + type StorageResult = M | null; + + type Storage = { + cache?: boolean | number; + get: (id?: ModelIdentifier) => StorageResult | Promise>; + set?: (id: ModelIdentifier, values: ModelValues | null, keys: [keyof M]) => StorageResult | Promise>; + list?: (id: ModelIdentifier) => StorageResult | Promise>; + } + + type StoreOptions = + | keyof E + | ((host: E) => string) + | { id?: keyof E, draft: boolean }; + + function store(Model: Model, options?: StoreOptions): Descriptor; + + namespace store { + const connect = "__store__connect__"; + + function get(Model: Model, id: ModelIdentifier): M; + function set(model: Model | M, values: ModelValues | null): Promise; + function clear(model: Model | M, clearValue?: boolean): void; + + function pending(model: M): false | Promise; + function error(model: M): false | Error | any; + function ready(model: M): boolean; + + function submit(draft: M): Promise; + + interface ValidateFunction { + (value: string | number, key: string, model: M): string | boolean | void; + } + + function value(defaultValue: string, validate?: ValidateFunction | RegExp, errorMessage?: string): string; + function value(defaultValue: number, validate?: ValidateFunction | RegExp, errorMessage?: string): number; + } + /* Utils */ + function dispatch(host: EventTarget, eventType: string, options?: CustomEventInit): boolean; /* Template Engine */ @@ -71,17 +133,21 @@ declare namespace hybrids { (host: E, event?: Event) : any; } + function html(parts: TemplateStringsArray, ...args: unknown[]): UpdateFunctionWithMethods; + namespace html { - function set(propertyName: keyof E, value?: any): EventHandler; + function set(property: keyof E, valueOrPath?: any): EventHandler; + function set(property: Model, valueOrPath: string | null): EventHandler; + function resolve(promise: Promise>, placeholder?: UpdateFunction, delay?: number): UpdateFunction; } - function html(parts: TemplateStringsArray, ...args: unknown[]): UpdateFunctionWithMethods; + function svg(parts: TemplateStringsArray, ...args: unknown[]): UpdateFunctionWithMethods; namespace svg { - function set(propertyName: keyof E, value?: any): EventHandler; + function set(property: keyof E, valueOrPath?: any): EventHandler; + function set(property: Model, valueOrPath: string | null): EventHandler; + function resolve(promise: Promise>, placeholder?: UpdateFunction, delay?: number) : UpdateFunction; } - - function svg(parts: TemplateStringsArray, ...args: unknown[]): UpdateFunctionWithMethods; } \ No newline at end of file