2020-10-06 21:31:56 +05:30
|
|
|
/*
|
|
|
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2021-09-30 06:00:21 +05:30
|
|
|
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
2022-02-16 23:37:10 +05:30
|
|
|
|
2020-10-06 21:31:56 +05:30
|
|
|
|
2022-02-21 17:37:30 +05:30
|
|
|
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
2022-02-21 17:30:46 +05:30
|
|
|
|
2022-06-07 13:28:56 +05:30
|
|
|
/**
|
|
|
|
* OptionalValue is basically stating that if SegmentType[type] = true:
|
|
|
|
* - Allow this type to be optional
|
|
|
|
* - Give it a default value of undefined
|
|
|
|
* - Also allow it to be true
|
|
|
|
* This lets us do:
|
|
|
|
* const s: Segment<SegmentType> = new Segment("create-room");
|
|
|
|
* instead of
|
|
|
|
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
|
|
|
|
*/
|
2022-02-22 13:14:27 +05:30
|
|
|
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
|
|
|
|
|
2022-03-02 17:57:03 +05:30
|
|
|
export class Navigation<T extends object> {
|
2022-02-21 17:37:30 +05:30
|
|
|
private readonly _allowsChild: AllowsChild<T>;
|
2022-02-18 16:07:18 +05:30
|
|
|
private _path: Path<T>;
|
|
|
|
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
|
|
|
|
private readonly _pathObservable: ObservableValue<Path<T>>;
|
2022-02-16 23:37:10 +05:30
|
|
|
|
2022-02-21 17:37:30 +05:30
|
|
|
constructor(allowsChild: AllowsChild<T>) {
|
2020-10-06 21:31:56 +05:30
|
|
|
this._allowsChild = allowsChild;
|
|
|
|
this._path = new Path([], allowsChild);
|
2020-10-16 16:16:14 +05:30
|
|
|
this._pathObservable = new ObservableValue(this._path);
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
get pathObservable(): ObservableValue<Path<T>> {
|
2020-10-16 16:16:14 +05:30
|
|
|
return this._pathObservable;
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
get path(): Path<T> {
|
2020-10-06 21:31:56 +05:30
|
|
|
return this._path;
|
|
|
|
}
|
|
|
|
|
2022-02-22 13:30:37 +05:30
|
|
|
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
|
2022-02-21 17:30:46 +05:30
|
|
|
const newPath = this.path.with(new Segment(type, ...value));
|
2022-02-16 23:37:10 +05:30
|
|
|
if (newPath) {
|
|
|
|
this.applyPath(newPath);
|
|
|
|
}
|
2020-10-16 16:16:14 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
applyPath(path: Path<T>): void {
|
2020-10-13 18:24:57 +05:30
|
|
|
// Path is not exported, so you can only create a Path through Navigation,
|
|
|
|
// so we assume it respects the allowsChild rules
|
2020-10-12 21:19:06 +05:30
|
|
|
const oldPath = this._path;
|
2020-10-06 21:31:56 +05:30
|
|
|
this._path = path;
|
2020-10-12 21:19:06 +05:30
|
|
|
// clear values not in the new path in reverse order of path
|
|
|
|
for (let i = oldPath.segments.length - 1; i >= 0; i -= 1) {
|
2020-10-12 22:01:55 +05:30
|
|
|
const segment = oldPath.segments[i];
|
2020-10-12 21:19:06 +05:30
|
|
|
if (!this._path.get(segment.type)) {
|
|
|
|
const observable = this._observables.get(segment.type);
|
2020-10-13 18:24:57 +05:30
|
|
|
observable?.emitIfChanged();
|
2020-10-12 21:19:06 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
// change values in order of path
|
|
|
|
for (const segment of this._path.segments) {
|
|
|
|
const observable = this._observables.get(segment.type);
|
2020-10-13 18:24:57 +05:30
|
|
|
observable?.emitIfChanged();
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
2020-10-16 16:16:14 +05:30
|
|
|
// to observe the whole path having changed
|
|
|
|
// Since paths are immutable,
|
|
|
|
// we can just use set here which will compare the references
|
|
|
|
this._pathObservable.set(this._path);
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
observe(type: keyof T): SegmentObservable<T> {
|
2020-10-06 21:31:56 +05:30
|
|
|
let observable = this._observables.get(type);
|
|
|
|
if (!observable) {
|
2020-10-13 18:24:57 +05:30
|
|
|
observable = new SegmentObservable(this, type);
|
2020-10-06 21:31:56 +05:30
|
|
|
this._observables.set(type, observable);
|
|
|
|
}
|
|
|
|
return observable;
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
pathFrom(segments: Segment<any>[]): Path<T> {
|
2022-02-16 23:37:10 +05:30
|
|
|
let parent: Segment<any> | undefined;
|
|
|
|
let i: number;
|
2020-10-06 21:31:56 +05:30
|
|
|
for (i = 0; i < segments.length; i += 1) {
|
|
|
|
if (!this._allowsChild(parent, segments[i])) {
|
|
|
|
return new Path(segments.slice(0, i), this._allowsChild);
|
|
|
|
}
|
|
|
|
parent = segments[i];
|
|
|
|
}
|
|
|
|
return new Path(segments, this._allowsChild);
|
|
|
|
}
|
2020-10-12 21:19:06 +05:30
|
|
|
|
2022-02-22 13:30:37 +05:30
|
|
|
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
|
2022-02-21 17:30:46 +05:30
|
|
|
return new Segment(type, ...value);
|
2020-10-12 21:19:06 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
|
2020-10-12 21:19:06 +05:30
|
|
|
if (a === b) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
// allow (sparse) arrays
|
|
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
|
|
const len = Math.max(a.length, b.length);
|
|
|
|
for (let i = 0; i < len; i += 1) {
|
|
|
|
if (a[i] !== b[i]) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
|
|
|
|
2022-02-16 23:37:10 +05:30
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
export class Segment<T, K extends keyof T = any> {
|
2022-02-21 17:30:46 +05:30
|
|
|
public value: T[K];
|
|
|
|
|
2022-02-22 13:30:37 +05:30
|
|
|
constructor(public type: K, ...value: OptionalValue<T[K]>) {
|
2022-02-21 17:30:46 +05:30
|
|
|
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
|
|
|
|
}
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
class Path<T> {
|
|
|
|
private readonly _segments: Segment<T, any>[];
|
2022-02-21 17:37:30 +05:30
|
|
|
private readonly _allowsChild: AllowsChild<T>;
|
2022-02-16 23:37:10 +05:30
|
|
|
|
2022-02-21 17:37:30 +05:30
|
|
|
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
|
2020-10-06 21:31:56 +05:30
|
|
|
this._segments = segments;
|
|
|
|
this._allowsChild = allowsChild;
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
clone(): Path<T> {
|
2020-10-06 21:31:56 +05:30
|
|
|
return new Path(this._segments.slice(), this._allowsChild);
|
|
|
|
}
|
|
|
|
|
2022-02-22 16:44:27 +05:30
|
|
|
with(segment: Segment<T>): Path<T> | undefined {
|
2020-10-06 21:31:56 +05:30
|
|
|
let index = this._segments.length - 1;
|
|
|
|
do {
|
|
|
|
if (this._allowsChild(this._segments[index], segment)) {
|
|
|
|
// pop the elements that didn't allow the new segment as a child
|
|
|
|
const newSegments = this._segments.slice(0, index + 1);
|
|
|
|
newSegments.push(segment);
|
|
|
|
return new Path(newSegments, this._allowsChild);
|
|
|
|
}
|
|
|
|
index -= 1;
|
|
|
|
} while(index >= -1);
|
|
|
|
// allow -1 as well so we check if the segment is allowed as root
|
2022-02-22 16:44:27 +05:30
|
|
|
return undefined;
|
2020-10-06 21:31:56 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
until(type: keyof T): Path<T> {
|
2020-10-12 21:19:06 +05:30
|
|
|
const index = this._segments.findIndex(s => s.type === type);
|
|
|
|
if (index !== -1) {
|
|
|
|
return new Path(this._segments.slice(0, index + 1), this._allowsChild)
|
|
|
|
}
|
|
|
|
return new Path([], this._allowsChild);
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
get(type: keyof T): Segment<T> | undefined {
|
2020-10-06 21:31:56 +05:30
|
|
|
return this._segments.find(s => s.type === type);
|
|
|
|
}
|
|
|
|
|
2022-02-22 16:44:27 +05:30
|
|
|
replace(segment: Segment<T>): Path<T> | undefined {
|
2021-04-21 19:03:08 +05:30
|
|
|
const index = this._segments.findIndex(s => s.type === segment.type);
|
|
|
|
if (index !== -1) {
|
|
|
|
const parent = this._segments[index - 1];
|
|
|
|
if (this._allowsChild(parent, segment)) {
|
|
|
|
const child = this._segments[index + 1];
|
|
|
|
if (!child || this._allowsChild(segment, child)) {
|
|
|
|
const newSegments = this._segments.slice();
|
|
|
|
newSegments[index] = segment;
|
|
|
|
return new Path(newSegments, this._allowsChild);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-02-22 16:44:27 +05:30
|
|
|
return undefined;
|
2021-04-21 19:03:08 +05:30
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
get segments(): Segment<T>[] {
|
2020-10-06 21:31:56 +05:30
|
|
|
return this._segments;
|
|
|
|
}
|
|
|
|
}
|
2020-10-13 18:24:57 +05:30
|
|
|
|
|
|
|
/**
|
|
|
|
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
|
|
|
|
* This ensures that observers of a segment can also read the most recent value of other segments.
|
|
|
|
*/
|
2022-03-02 17:57:03 +05:30
|
|
|
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
|
2022-02-18 16:07:18 +05:30
|
|
|
private readonly _navigation: Navigation<T>;
|
|
|
|
private _type: keyof T;
|
|
|
|
private _lastSetValue?: T[keyof T];
|
2022-02-16 23:37:10 +05:30
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
constructor(navigation: Navigation<T>, type: keyof T) {
|
2020-10-13 18:24:57 +05:30
|
|
|
super();
|
|
|
|
this._navigation = navigation;
|
|
|
|
this._type = type;
|
|
|
|
this._lastSetValue = navigation.path.get(type)?.value;
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
get(): T[keyof T] | undefined {
|
2020-10-13 18:24:57 +05:30
|
|
|
const path = this._navigation.path;
|
|
|
|
const segment = path.get(this._type);
|
|
|
|
const value = segment?.value;
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
|
2022-02-16 23:37:10 +05:30
|
|
|
emitIfChanged(): void {
|
2020-10-13 18:24:57 +05:30
|
|
|
const newValue = this.get();
|
2022-02-18 16:07:18 +05:30
|
|
|
if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
|
2020-10-13 18:24:57 +05:30
|
|
|
this._lastSetValue = newValue;
|
|
|
|
this.emit(newValue);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-22 11:48:20 +05:30
|
|
|
export type {Path};
|
2022-02-22 11:41:36 +05:30
|
|
|
|
2020-10-13 18:24:57 +05:30
|
|
|
export function tests() {
|
|
|
|
|
|
|
|
function createMockNavigation() {
|
|
|
|
return new Navigation((parent, {type}) => {
|
|
|
|
switch (parent?.type) {
|
|
|
|
case undefined:
|
2022-02-21 17:37:30 +05:30
|
|
|
return type === "1" || type === "2";
|
2020-10-13 18:24:57 +05:30
|
|
|
case "1":
|
|
|
|
return type === "1.1";
|
|
|
|
case "1.1":
|
|
|
|
return type === "1.1.1";
|
|
|
|
case "2":
|
2022-02-21 17:37:30 +05:30
|
|
|
return type === "2.1" || type === "2.2";
|
2020-10-13 18:24:57 +05:30
|
|
|
default:
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function observeTypes(nav, types) {
|
2022-02-18 16:07:18 +05:30
|
|
|
const changes: {type:string, value:any}[] = [];
|
2020-10-13 18:24:57 +05:30
|
|
|
for (const type of types) {
|
|
|
|
nav.observe(type).subscribe(value => {
|
|
|
|
changes.push({type, value});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return changes;
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:07:18 +05:30
|
|
|
type SegmentType = {
|
|
|
|
"foo": number;
|
|
|
|
"bar": number;
|
|
|
|
"baz": number;
|
|
|
|
}
|
|
|
|
|
2020-10-13 18:24:57 +05:30
|
|
|
return {
|
|
|
|
"applying a path emits an event on the observable": assert => {
|
|
|
|
const nav = createMockNavigation();
|
|
|
|
const path = nav.pathFrom([
|
|
|
|
new Segment("2", 7),
|
|
|
|
new Segment("2.2", 8),
|
|
|
|
]);
|
|
|
|
assert.equal(path.segments.length, 2);
|
|
|
|
let changes = observeTypes(nav, ["2", "2.2"]);
|
|
|
|
nav.applyPath(path);
|
|
|
|
assert.equal(changes.length, 2);
|
|
|
|
assert.equal(changes[0].type, "2");
|
|
|
|
assert.equal(changes[0].value, 7);
|
|
|
|
assert.equal(changes[1].type, "2.2");
|
|
|
|
assert.equal(changes[1].value, 8);
|
|
|
|
},
|
|
|
|
"path.get": assert => {
|
2022-02-18 16:07:18 +05:30
|
|
|
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
|
|
|
assert.equal(path.get("foo")!.value, 5);
|
|
|
|
assert.equal(path.get("bar")!.value, 6);
|
2021-04-28 13:23:44 +05:30
|
|
|
},
|
|
|
|
"path.replace success": assert => {
|
2022-02-18 16:07:18 +05:30
|
|
|
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
2021-04-28 13:23:44 +05:30
|
|
|
const newPath = path.replace(new Segment("foo", 1));
|
2022-02-18 16:07:18 +05:30
|
|
|
assert.equal(newPath!.get("foo")!.value, 1);
|
|
|
|
assert.equal(newPath!.get("bar")!.value, 6);
|
2021-04-28 13:23:44 +05:30
|
|
|
},
|
|
|
|
"path.replace not found": assert => {
|
2022-02-18 16:07:18 +05:30
|
|
|
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
2021-04-28 13:23:44 +05:30
|
|
|
const newPath = path.replace(new Segment("baz", 1));
|
|
|
|
assert.equal(newPath, null);
|
2020-10-13 18:24:57 +05:30
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|