Last active
May 18, 2018 09:33
-
-
Save misantronic/83c41bc89aa4ce44c3ed8213dc75a32d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { db, binding, vm, misc } from 'baqend'; | |
import { | |
observable, | |
decorate, | |
isObservableArray, | |
reaction, | |
IReactionDisposer | |
} from 'mobx'; | |
function isObject(obj): boolean { | |
return ( | |
obj && | |
!Array.isArray(obj) && | |
!isObservableArray(obj) && | |
!(obj instanceof Date) && | |
typeof obj === 'object' | |
); | |
} | |
function isArray<T = any>(obj): obj is Array<T> { | |
return Array.isArray(obj) || isObservableArray(obj); | |
} | |
const viewModelClasses: { [key: string]: any } = {}; | |
const Entities: binding.EntityFactory<any>[] = []; | |
const listener = new Map<string, Set<ViewModelBase>>(); | |
function isViewmodel(obj: any): obj is vm.ViewModel { | |
return Boolean(obj && obj.$ref); | |
} | |
async function ensureMetadata(item: any): Promise<void> { | |
if (item instanceof db.File) { | |
if (!item.isMetadataLoaded) { | |
await item.loadMetadata(); | |
} | |
} else if (isObject(item)) { | |
for (const [key, value] of Object.entries(item)) { | |
if (key === 'acl') { | |
continue; | |
} | |
await ensureMetadata(value); | |
} | |
} else if (isArray(item)) { | |
for (const arrayItem of item) { | |
await ensureMetadata(arrayItem); | |
} | |
} | |
} | |
function getEntityKeys(item: binding.Entity) { | |
const Entity = item | |
.toString() | |
.replace(/^\/db\//, '') | |
.match(/^(\w+)\/?.*/)![1]; | |
return { keys: Object.keys(db[Entity].prototype), Entity }; | |
} | |
function toDate(date: Date | undefined): Date { | |
return (date ? new Date(date) : undefined) as any; | |
} | |
function isEntity(entity: any): entity is binding.Entity { | |
if (Entities.length === 0) { | |
Entities.push( | |
...Object.keys(db.metamodel.entities) | |
.map(key => key.replace(/^\/db\//, '')) | |
.map(Entity => db[Entity]) | |
.filter(entity => Boolean(entity)) | |
); | |
} | |
return Entities.some(Entitiy => entity instanceof Entitiy); | |
} | |
function getFileUrl(file: binding.File): string { | |
return file.url | |
.replace(':8080', '.app.baqend.com') | |
.replace(/\?BAT=(?:\w+)/, ''); | |
} | |
function convertFilesToJSON(ref: binding.Entity, base: { [key: string]: any }) { | |
Object.keys(base).map(key => { | |
const itemRef = ref[key]; | |
if (itemRef instanceof db.File) { | |
let headers = {}; | |
try { | |
headers = itemRef.headers; | |
} catch (_e) {} | |
base[key] = { | |
id: itemRef.id, | |
url: getFileUrl(itemRef), | |
headers | |
}; | |
} else if (isArray(itemRef)) { | |
itemRef.forEach((subItemRef, i) => { | |
if (subItemRef instanceof db.File) { | |
let headers = {}; | |
try { | |
headers = subItemRef.headers; | |
} catch (_e) {} | |
base[key][i] = { | |
id: subItemRef.id, | |
url: getFileUrl(subItemRef), | |
headers | |
}; | |
} else if (isEntity(subItemRef)) { | |
convertFilesToJSON(subItemRef, base[key][i]); | |
} | |
}); | |
} else if (isEntity(itemRef)) { | |
convertFilesToJSON(itemRef, base[key]); | |
} | |
}); | |
} | |
function getViewmodelConfig(item: binding.Entity) { | |
const { keys, Entity } = getEntityKeys(item); | |
if (viewModelClasses[Entity]) { | |
return { keys, Entity, ViewModel: viewModelClasses[Entity] }; | |
} | |
function ViewModel( | |
this: ViewModelBase, | |
ref: binding.Entity, | |
type: string, | |
keys: string[] | |
) { | |
this.$ref = ref; | |
this.$type = type; | |
this.key = ref.key; | |
this.id = ref.id; | |
this.updatedAt = toDate(ref.updatedAt); | |
this.createdAt = toDate(ref.createdAt); | |
this.acl = ref.acl.toJSON() as misc.AclJSON; | |
this.version = ref.version; | |
if (!this.disposeReactions) { | |
this.disposeReactions = []; | |
} | |
this.disposeReactions.forEach(disposer => disposer()); | |
this.disposeReactions = []; | |
keys.forEach(key => { | |
let value; | |
try { | |
value = ref[key]; | |
} catch (_e) {} | |
if (isArray(value)) { | |
value = value.map(toJSON); | |
} | |
if (value instanceof Set) { | |
const newValue = []; | |
for (const v of value) { | |
newValue.push(toJSON(v)); | |
} | |
value = newValue; | |
} | |
this[key] = toJSON(value); | |
this.disposeReactions.push( | |
reaction(() => this[key], () => (this.dirty = true)) | |
); | |
}); | |
this.addListener(); | |
} | |
ViewModel.prototype = Object.create(ViewModelBase.prototype); | |
ViewModel.prototype.constructor = ViewModel; | |
decorate( | |
ViewModel, | |
keys.reduce( | |
(memo, key) => ({ | |
...memo, | |
[key]: observable | |
}), | |
{} as any | |
) | |
); | |
viewModelClasses[Entity] = ViewModel; | |
return { keys, Entity, ViewModel }; | |
} | |
export function clearViewModelListeners(): void { | |
listener.clear(); | |
} | |
class ViewModelBase implements vm.ViewModel { | |
public $ref!: binding.Entity; | |
public $type!: string; | |
// @ts-ignore | |
@observable public acl!: misc.AclJSON; | |
@observable public key!: string; | |
@observable public id!: string; | |
@observable public updatedAt!: Date; | |
@observable public createdAt!: Date; | |
@observable public version!: number; | |
@observable public dirty = false; | |
public disposeReactions!: IReactionDisposer[]; | |
private sync(entity: binding.Entity): binding.Entity { | |
const { keys, Entity } = getViewmodelConfig(entity); | |
if (listener.has(this.id)) { | |
// update all viewModels with the same id | |
const viewmodels = listener.get(this.id); | |
if (viewmodels) { | |
for (const viewModel of viewmodels) { | |
viewModel.constructor.call(viewModel, entity, Entity, keys); | |
} | |
} | |
} | |
this.dirty = false; | |
return entity; | |
} | |
public async delete(options?: misc.DeleteOptions): Promise<any> { | |
return await this.fromJSON().delete(options); | |
} | |
public async save(options?: misc.SaveOptions): Promise<any> { | |
const $ref = this.fromJSON(options); | |
if (this.dirty) { | |
await ensureMetadata(this); | |
} | |
const result = await $ref.save(options); | |
return this.sync(result); | |
} | |
public async update(options?: misc.SaveOptions): Promise<any> { | |
const $ref = this.fromJSON(options); | |
if (this.dirty) { | |
await ensureMetadata(this); | |
} | |
const result = await $ref.update(options); | |
return this.sync(result); | |
} | |
public async insert(options?: misc.InsertOptions): Promise<any> { | |
const result = await this.fromJSON().insert(options); | |
return this.sync(result); | |
} | |
public async load(_options?: misc.LoadOptions): Promise<any> { | |
throw new Error('Not implemented yet'); | |
} | |
public async ready(): Promise<any> { | |
throw new Error('Not implemented yet'); | |
} | |
public async attach(): Promise<any> { | |
throw new Error('Not implemented yet'); | |
} | |
public async optimisticSave(): Promise<any> { | |
throw new Error('Not implemented yet'); | |
} | |
public async validate(): Promise<any> { | |
throw new Error('Not implemented yet'); | |
} | |
public getReferencing(): any { | |
throw new Error('Not implemented yet'); | |
} | |
public toJSON(options?: misc.ToJSONOptions): any { | |
const json = this.$ref.toJSON(options); | |
if (options && options.files) { | |
convertFilesToJSON(this.$ref, json); | |
} | |
return json; | |
} | |
public fromJSON(options?: { force?: boolean }): binding.Entity { | |
const { keys } = getEntityKeys(this.$ref); | |
// sync changes back | |
keys.forEach(key => { | |
try { | |
const refValue = this.$ref[key]; | |
const viewModelValue = this[key]; | |
if (isViewmodel(viewModelValue)) { | |
this.$refAssign(key, viewModelValue.$ref); | |
} else if ( | |
refValue instanceof Set && | |
isObservableArray(viewModelValue) | |
) { | |
this.$refAssign( | |
key, | |
new Set( | |
viewModelValue.map( | |
(item: vm.ViewModel) => item.$ref | |
) | |
) | |
); | |
} else if (isArray(viewModelValue)) { | |
this.$refAssign( | |
key, | |
viewModelValue.map(item => { | |
if (isViewmodel(item)) { | |
return item.$ref; | |
} | |
return item; | |
}) | |
); | |
} else { | |
this.$refAssign(key, viewModelValue); | |
} | |
} catch (_e) {} | |
}); | |
// handle acl | |
this.$ref.acl.fromJSON(this.acl); | |
// TODO: not an optimal solution | |
if (options && options.force) { | |
// force update some prop | |
this.$ref[keys[0]] = this.$ref[keys[0]]; | |
this.dirty = true; | |
} | |
return this.$ref; | |
} | |
public addListener(): void { | |
if (!listener.has(this.id)) { | |
listener.set(this.id, new Set<ViewModelBase>()); | |
} | |
listener.get(this.id)!.add(this); | |
} | |
private $refAssign(key: string, newVal: any): void { | |
const refValue = this.$ref[key]; | |
// compare Array-changes | |
if ( | |
isArray(newVal) && | |
newVal.length === refValue.length && | |
newVal.every((item, i) => item === refValue[i]) | |
) { | |
return; | |
} | |
// compare Set-changes | |
if ( | |
newVal instanceof Set && | |
refValue instanceof Set && | |
newVal.size === refValue.size && | |
Array.from(newVal).every(item => refValue.has(item)) | |
) { | |
return; | |
} | |
// compare atomic-changes | |
if (refValue !== newVal) { | |
this.$ref[key] = newVal; | |
} | |
} | |
} | |
export function toJSON<T = any>(item: binding.Entity | vm.ViewModel): T { | |
if (isViewmodel(item)) { | |
item = item.$ref; | |
} | |
if (!isEntity(item)) { | |
return item as any; | |
} | |
const { keys, ViewModel, Entity } = getViewmodelConfig(item); | |
const viewmodel: ViewModelBase = new ViewModel(item, Entity, keys); | |
// console.log('viewmodel', viewmodel); | |
return viewmodel as any; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment