Bug 1541629 - Part 1: Create new Resource utility for common Redux state operations. r=jlast

Differential Revision: https://phabricator.services.mozilla.com/D27956

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Logan Smyth 2019-04-19 16:11:47 +00:00
Родитель 502d99561a
Коммит 74f51f53d5
7 изменённых файлов: 752 добавлений и 0 удалений

Просмотреть файл

@ -7,6 +7,7 @@ DIRS += [
'breakpoint',
'editor',
'pause',
'resource',
'sources-tree',
]

Просмотреть файл

@ -0,0 +1,302 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// @flow
import {
mutateState,
createOrdered,
createUnordered,
type Resource,
type Id,
type Item,
type Fields,
type State,
type Order,
type UnorderedState,
type OrderedState,
type MutableResource,
type MutableFields
} from "./core";
/**
* Provide the default Redux state for an unordered store.
*/
export function createInitial<R: Resource>(
fields: Fields<R>
): UnorderedState<R> {
return createUnordered(fields);
}
/**
* Provide the default Redux state for an ordered store.
*/
export function createInitialOrdered<R: Resource>(
fields: Fields<R>
): OrderedState<R> {
return createOrdered(fields);
}
/**
* Splice a given item into the resource set at a given index.
*/
export function insertResourceAtIndex<R: Resource>(
state: OrderedState<R>,
resource: R,
index: number
): OrderedState<R> {
return mutateState(
state,
{ resources: [resource], index },
insertFieldsMutator,
insertOrderMutator
);
}
export function insertResourcesAtIndex<R: Resource>(
state: OrderedState<R>,
resources: Array<R>,
index: number
): OrderedState<R> {
if (resources.length === 0) {
return state;
}
return mutateState(
state,
{ resources, index },
insertFieldsMutator,
insertOrderMutator
);
}
/**
* Insert a given item into the resource set, at the end of the order if the
* resource is an ordered one.
*/
export function insertResource<R: Resource, S: State<R>>(
state: S,
resource: R
): S {
return mutateState(
state,
{ resources: [resource] },
insertFieldsMutator,
insertOrderMutator
);
}
export function insertResources<R: Resource, S: State<R>>(
state: S,
resources: Array<R>
): S {
if (resources.length === 0) {
return state;
}
return mutateState(
state,
{ resources },
insertFieldsMutator,
insertOrderMutator
);
}
/**
* Remove a given item from the resource set, including any data.
*
* Note: This function here requires an Item because we want to encourage users
* of this API to include the "item" type in their action payload. This allows
* for secondary reducers that might be handling the event to have a bit more
* metadata about an item, since they won't be able to look it up in the state.
*/
export function removeResource<R: Resource, S: State<R>>(
state: S,
item: Item<R>
): S {
return mutateState(state, [item], removeFieldsMutator, removeOrderMutator);
}
export function removeResources<R: Resource, S: State<R>>(
state: S,
items: Array<Item<R>>
): S {
if (items.length === 0) {
return state;
}
return mutateState(state, items, removeFieldsMutator, removeOrderMutator);
}
/**
* Set a single resource value for a single resource.
*/
export function setFieldValue<R: Resource, K: $Keys<R>, S: State<R>>(
state: S,
field: K,
id: Id<R> | Item<R>,
value: $ElementType<R, K>
): S {
if (typeof id !== "string") {
id = id.id;
}
return setFieldsValue(state, field, { [id]: value });
}
/**
* Set a single resource value for multiple resources.
*/
export function setFieldValues<
R: Resource,
K: $Keys<$Rest<Fields<R>, { item: $ElementType<Fields<R>, "item"> }>>,
S: State<R>
>(state: S, field: K, fieldValues: { [Id<R>]: $ElementType<R, K> }): S {
return setFieldsValues(state, { [field]: fieldValues });
}
/**
* Set multiple resource values for a single resource.
*/
export function setFieldsValue<R: Resource, S: State<R>>(
state: S,
id: Id<R> | Item<R>,
idValues: MutableResource<R>
): S {
if (typeof id !== "string") {
id = id.id;
}
const fields = {};
for (const key of Object.keys(idValues)) {
fields[key] = { [id]: idValues[key] };
}
return setFieldsValues(state, fields);
}
/**
* Set multiple resource values for a multiple resource.
*/
export function setFieldsValues<R: Resource, S: State<R>>(
state: S,
fields: $Shape<MutableFields<R>>
): S {
return mutateState(state, fields, setFieldsMutator);
}
function setFieldsMutator<R: Resource>(
newFields: $Shape<MutableFields<R>>,
fields: Fields<R>
): void {
if ("item" in newFields) {
throw new Error(
"Resource items cannot be updated. Remove the resource and recreate " +
"it to change the item. Ideally though you should consider creating " +
"an entirely new ID so that items are fully unique and immutable."
);
}
for (const field of Object.keys(newFields)) {
if (!Object.prototype.hasOwnProperty.call(fields, field)) {
console.error(
`Resource fields contains the property "${field}", which is unknown. ` +
"The resource initial value should be updated to " +
"include the new property. If you are seeing this and get no " +
"Flow errors, please make sure that you are not using any " +
"optional properties in your primary resource item type and " +
"that it is an exact object."
);
}
const fieldsValues = newFields[field];
if (fieldsValues) {
fields[field] = { ...fields[field] };
for (const id of Object.keys(fieldsValues)) {
if (!Object.prototype.hasOwnProperty.call(fields.item, id)) {
throw new Error(`Resource item ${id} not found, cannot set ${field}`);
}
fields[field][id] = fieldsValues[id];
}
}
}
}
function insertFieldsMutator<R: Resource>(
{
resources
}: {
resources: Array<R>,
index?: number
},
fields: Fields<R>
): void {
for (const key of Object.keys(fields)) {
fields[key] = { ...fields[key] };
}
for (const resource of resources) {
const { id } = resource.item;
if (Object.prototype.hasOwnProperty.call(fields.item, id)) {
throw new Error(`Resource item ${id} already exists`);
}
for (const key of Object.keys(fields)) {
fields[key][id] = resource[key];
}
for (const key of Object.keys(resource)) {
if (!Object.prototype.hasOwnProperty.call(fields, key)) {
console.error(
`Resource ${id} contains the property "${key}", which is unknown.` +
"The resource initial value should be updated to " +
"include the new property. If you are seeing this and get no " +
"Flow errors, please make sure that you are not using any " +
"optional properties in your primary resource item type and " +
"that it is an exact object."
);
}
}
}
}
function insertOrderMutator<R: Resource>(
{
resources,
index
}: {
resources: Array<R>,
index?: number
},
order: Order<R>
): void {
order.splice(
typeof index === "number" ? index : order.length,
0,
...resources.map(r => r.item.id)
);
}
function removeFieldsMutator<R: Resource>(
items: Array<Item<R>>,
fields: Fields<R>
): void {
for (const key of Object.keys(fields)) {
fields[key] = { ...fields[key] };
}
for (const { id } of items) {
if (!Object.prototype.hasOwnProperty.call(fields.item, id)) {
throw new Error(`Resource item ${id} not found, cannot remove`);
}
for (const key of Object.keys(fields)) {
delete fields[key][id];
}
}
}
function removeOrderMutator<R: Resource>(
items: Array<Item<R>>,
order: Order<R>
): Order<R> {
const ids = new Set(items.map(item => item.id));
return order.filter(id => ids.has(id));
}

Просмотреть файл

@ -0,0 +1,155 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// @flow
import { getFields, type Resource, type Id, type State } from "./core";
/**
* A simple wrapper on createFieldReducer to take a set of IDs and get the
* current values for a given field, in the target resource.
*
* Caches results based on object identity. See createFieldReducer for
* more information about what specific caching is performed.
*/
export type FieldByIDGetter<R: Resource, K: $Keys<R>> = FieldByIDMapper<
R,
$ElementType<R, K>
>;
export function createFieldByIDGetter<R: Resource, K: $Keys<R>>(
field: K
): FieldByIDGetter<R, K> {
return createFieldByIDMapper(field, v => v);
}
/**
* A simple wrapper on createFieldReducer for mapping a set of IDs to
* a set of values.
*
* Caches results based on object identity. See createFieldReducer for
* more information about what specific caching is performed.
*/
export type FieldByIDMapper<R: Resource, T> = FieldByIDReducer<R, Array<T>>;
export function createFieldByIDMapper<R: Resource, K: $Keys<R>, T>(
field: K,
mapper: (value: $ElementType<R, K>, id: Id<R>, i: number) => T
): FieldByIDMapper<R, T> {
return createFieldByIDReducer(
field,
(acc, value, id, i) => {
acc.push(mapper(value, id, i));
return acc;
},
() => []
);
}
/**
* Generic utility for taking a given array of IDs for a type, and loading
* data for those IDs from a given field for that type, and then reducing
* those values into a final result.
*
* If the returned callback is passed the exact same array of IDs and the
* same state value as the previous call with those IDs, it will return
* the exact same result as last time.
*
* If the returned callback is passed the exact same array of IDs and a
* different state, we will check if all of the same IDs point an unchanged
* values, we will also reused the previously calculated result.
*
* If the set of values referenced by the IDs has changed, then the reducer
* will be executed to create a new cached value for this set of IDs.
*/
export type FieldByIDReducer<R: Resource, T> = (
State<R>,
$ReadOnlyArray<Id<R>> | Set<Id<R>>
) => T;
export function createFieldByIDReducer<R: Resource, K: $Keys<R>, T>(
field: K,
reducer: (acc: T, value: $ElementType<R, K>, id: Id<R>, i: number) => T,
initial: () => T
): FieldByIDReducer<R, T> {
const cache = new WeakMap();
const handler = (acc, { id, value }, i) => reducer(acc, value, id, i);
return (state, ids) => {
const fields = getFields(state);
const fieldValues = fields[field];
if (!fieldValues) {
throw new Error(`Field "${field}" does not exist in this resource.`);
}
let result = cache.get(ids);
if (!result || result.fieldValues !== fieldValues) {
if (!Array.isArray(ids)) {
ids = Array.from(ids);
}
const items = ids.map(id => {
if (!Object.prototype.hasOwnProperty.call(fields.item, id)) {
throw new Error(`Resource item ${id} not found`);
}
return {
id,
value: fieldValues[id]
};
});
// If the resource object itself changed but we still have the same set
// of result values, we can also reuse the previously calculated result.
if (
!result ||
result.items.length !== items.length ||
result.items.some(
(item, i) => items[i].id !== item.id || items[i].value !== item.value
)
) {
result = {
fieldValues,
items,
value: items.reduce(handler, initial())
};
cache.set(ids, result);
} else {
result.fieldValues = fieldValues;
}
}
return result.value;
};
}
/**
* Builds a memozed result based on all of the values for a given field.
*/
export type FieldReducer<R: Resource, T> = (State<R>) => T;
export function createFieldReducer<R: Resource, K: $Keys<R>, T>(
field: K,
reducer: (acc: T, value: $ElementType<R, K>, id: Id<R>) => T,
initial: () => T
): FieldReducer<R, T> {
const cache = new WeakMap();
return state => {
const fieldValues = getFields(state)[field];
if (cache.has(fieldValues)) {
// Flow isn't quite smary enough to know that "has" returning true means
// that we'll always get the expected value, so we have to cast to 'any'.
// We specifically use 'has' because we want be flexible and allow the
// 'reducer' function to return 'undefined' if it wants to.
return (cache.get(fieldValues): any);
}
let result = initial();
for (const id of Object.keys(fieldValues)) {
result = reducer(result, fieldValues[id], id);
}
cache.set(fieldValues, result);
return result;
};
}

Просмотреть файл

@ -0,0 +1,122 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// @flow
/**
* This file defines the root resource datatypes and functions to manipulate
* them. We specifically want these types to be opaque values because we want
* to restrict access to them to the set of functions exposed specifically
* for manipulation in order to promote good methods of access and good
* memoization logic.
*/
type PropertiesById<ID, T> = $Exact<$ObjMap<T, <V>(v: V) => { [ID]: V }>>;
export type Resource = {
+item: { +id: string }
};
export type Value<R: Resource, K: $Keys<R>> = $ElementType<R, K>;
export type Item<R: Resource> = Value<R, "item">;
export type Id<R: Resource> = $ElementType<Item<R>, "id">;
export type Order<R: Resource> = Array<Id<R>>;
export type ReadOnlyOrder<R: Resource> = $ReadOnlyArray<Id<R>>;
export type Fields<R: Resource> = {|
// This spread clears the covariance on 'item'.
...PropertiesById<Id<R>, R>
|};
export type ReadOnlyFields<R: Resource> = $ReadOnly<Fields<R>>;
// The resource type without "item" so it is all the types you're allow to
// mutate via the resource API.
export type MutableResource<R: Resource> = $Rest<
R,
{ item: $ElementType<R, "item"> }
>;
export type MutableFields<R: Resource> = PropertiesById<
Id<R>,
MutableResource<R>
>;
// The general structure of a state store. Each top-level property of the
// UnorderedState type will be stored as its own key/value object since the
// types will likely mutate independent of one another and this approach
// allows us to easily memoize values based on Redux selectors.
export opaque type UnorderedState<R: Resource> = {
fields: ReadOnlyFields<R>
};
// An ordered version of the state where order is represented
// as an array alongside the item lookup so that reordering items
// does not need to affect any results that may have been computed
// over the items as a whole ignoring sort order.
export opaque type OrderedState<R: Resource> = {
order: ReadOnlyOrder<R>,
fields: ReadOnlyFields<R>
};
export type State<R: Resource> = OrderedState<R> | UnorderedState<R>;
/**
* Provide the default Redux state for an unordered store.
*/
export function createUnordered<R: Resource>(
fields: Fields<R>
): UnorderedState<R> {
if (Object.keys(fields.item).length !== 0) {
throw new Error("The initial 'fields' object should be empty.");
}
return {
fields
};
}
/**
* Provide the default Redux state for an ordered store.
*/
export function createOrdered<R: Resource>(fields: Fields<R>): OrderedState<R> {
if (Object.keys(fields.item).length !== 0) {
throw new Error("The initial 'fields' object should be empty.");
}
return {
order: [],
fields
};
}
export function mutateState<R: Resource, S: State<R>, T>(
state: S,
arg: T,
fieldsMutator: (T, Fields<R>) => void,
orderMutator?: (T, Order<R>) => Order<R> | void
): S {
// $FlowIgnore - Flow can't quite tell that this is a valid way to copy state.
state = { ...state };
state.fields = { ...state.fields };
fieldsMutator(arg, state.fields);
if (orderMutator && state.order) {
const mutableOrder = state.order.slice();
state.order = orderMutator(arg, mutableOrder) || mutableOrder;
}
return state;
}
export function getOrder<R: Resource>(
state: OrderedState<R>
): ReadOnlyOrder<R> {
return state.order;
}
export function getFields<R: Resource>(state: State<R>): ReadOnlyFields<R> {
return state.fields;
}

Просмотреть файл

@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// @flow
import type { UnorderedState, OrderedState } from "./core";
export type {
UnorderedState as ResourceState,
OrderedState as OrderedResourceState
};
export type {
FieldByIDGetter,
FieldByIDMapper,
FieldByIDReducer,
FieldReducer
} from "./caching";
export {
createInitial,
createInitialOrdered,
insertResourceAtIndex,
insertResourcesAtIndex,
insertResource,
insertResources,
removeResource,
removeResources,
setFieldsValue,
setFieldsValues,
setFieldValue,
setFieldValues
} from "./actions";
export {
hasResource,
getResource,
listItems,
getItem,
getFieldValue
} from "./selectors";
export {
createFieldByIDGetter,
createFieldByIDMapper,
createFieldByIDReducer,
createFieldReducer
} from "./caching";

Просмотреть файл

@ -0,0 +1,15 @@
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DIRS += [
]
CompiledModules(
'actions.js',
'caching.js',
'core.js',
'index.js',
'selectors.js'
)

Просмотреть файл

@ -0,0 +1,112 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// @flow
import {
getOrder,
getFields,
type Resource,
type Id,
type Item,
type OrderedState,
type State
} from "./core";
/**
* Verify that a resource exists with the given ID.
*/
export function hasResource<R: Resource>(
state: State<R>,
id: Id<R> | Item<R>
): boolean {
if (typeof id !== "string") {
id = id.id;
}
return Object.prototype.hasOwnProperty.call(getFields(state).item, id);
}
/**
* Get the overall Resource value for a given ID.
*
* When using this function, please consider whether it may make more sense
* to create a cached selector using one of the 'createFieldXXXXXXX' helpers.
*/
export function getResource<R: Resource>(
state: State<R>,
id: Id<R> | Item<R>
): R {
if (typeof id !== "string") {
id = id.id;
}
if (!hasResource(state, id)) {
throw new Error(`Resource ${id} not found`);
}
const fields = getFields(state);
// We have to do some typecasting here because while we know that the the
// types are the same, Flow isn't quite able to guarantee that the returned
// Resource here will actually match the type.
const resource: { [string]: mixed } = {};
for (const key of Object.keys(fields)) {
resource[key] = fields[key][id];
}
return (resource: any);
}
/**
* Get a list of all of the items in an ordered resource.
*/
const listCache: WeakMap<Array<mixed>, Array<mixed>> = new WeakMap();
export function listItems<R: Resource>(state: OrderedState<R>): Array<Item<R>> {
const order = getOrder(state);
// Note: The 'any' casts here are necessary because Flow has no way to know
// that the 'R' type of 'order' and 'items' is required to match.
let items: Array<Item<R>> | void = (listCache.get((order: any)): any);
if (!items) {
// Since items can't be changed, the order array's identity can fully
// cache this item load. Changes to items would have changed the order too.
items = order.map(id => getItem(state, id));
listCache.set((order: any), (items: any));
}
return items;
}
export function getItem<R: Resource>(state: State<R>, id: Id<R>): Item<R> {
if (!hasResource(state, id)) {
throw new Error(`Resource item ${id} not found`);
}
return getFields(state).item[id];
}
/**
* Look up a given item in the resource based on its ID. Throws if the item
* is not found.
*/
export function getFieldValue<R: Resource, K: $Keys<R>>(
state: State<R>,
field: K,
id: Id<R> | Item<R>
): $ElementType<R, K> {
if (typeof id !== "string") {
id = id.id;
}
if (!hasResource(state, id)) {
throw new Error(`Resource item ${id} not found`);
}
const fieldValues = getFields(state)[field];
if (!fieldValues) {
throw new Error(`Resource corrupt: Field "${field}" not found`);
}
// We don't bother to check if this ID exists in 'values' because if it
// does not, and things typechecked, then it means that the inserted
// resource was missing that key and returning undefined is fine.
return fieldValues[id];
}