diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5d2e159..b066862 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -3,8 +3,8 @@ name: Build, Test & Deploy demo # Controls when the action will run. on: # Triggers the workflow on push or pull request events but only for the master branch - push: - branches: [master] + # push: + # branches: [master] pull_request: branches: [master] types: [opened, synchronize, reopened] diff --git a/README.md b/README.md index 5381b08..314c210 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # @ngx-odm/rxdb -> Angular 10+ wrapper for **RxDB** - A realtime Database for the Web +> Angular 14+ wrapper for **RxDB** - A realtime Database for the Web ## Demo -![Example screenshot](examples/demo/src/assets/images/screenshot.png) +![Example Screencast](examples/screencast.gif) [demo](https://voznik.github.io/ngx-odm/) - based on TodoMVC @@ -36,7 +36,7 @@ If you don't want to setup RxDB manually in your next Angular project - just imp ## Technologies -| RxDB | Angular 10+ | +| RxDB | Angular 14+ | | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | [![RxDB](https://cdn.rawgit.com/pubkey/rxdb/ba7c9b80/docs/files/logo/logo_text.svg)](https://rxdb.info/) | [![Angular](https://angular.io/assets/images/logos/angular/angular.svg)](https://angular.io/) | @@ -149,7 +149,7 @@ export class TodosService { // remove many dcouments by qeury removeCompletedTodos(): void { - cthis.collectionService.removeBulk({ selector: { completed: true } }); + this.collectionService.removeBulk({ selector: { completed: true } }); } // ... } diff --git a/examples/demo/src/app/todos/components/todos/todos.component.css b/examples/demo/src/app/todos/components/todos/todos.component.css deleted file mode 100644 index f167269..0000000 --- a/examples/demo/src/app/todos/components/todos/todos.component.css +++ /dev/null @@ -1,5 +0,0 @@ -.clear-completed:disabled { - color: #999; - cursor: not-allowed; - text-decoration: none; -} diff --git a/examples/demo/src/app/todos/models/index.ts b/examples/demo/src/app/todos/models/index.ts deleted file mode 100644 index f48346b..0000000 --- a/examples/demo/src/app/todos/models/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// start:ng42.barrel -export * from './todos.model'; -export * from './todos.schema'; -// end:ng42.barrel diff --git a/examples/demo/src/app/todos/models/todos.model.ts b/examples/demo/src/app/todos/models/todos.model.ts deleted file mode 100755 index bf547f5..0000000 --- a/examples/demo/src/app/todos/models/todos.model.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface Todo { - id: string; - title: string; - completed: boolean; - createdAt: string; - last_modified: number; -} - -export type TodosFilter = 'ALL' | 'COMPLETED' | 'ACTIVE'; - -export interface TodosState { - items: Todo[]; - filter: TodosFilter; -} - -export const TODOS_INITIAL_STATE: TodosState = { - items: [ - { - id: 'ac3ef2c6-c98b-43e1-9047-71d68b1f92f4', - title: 'Open Todo list example', - completed: true, - createdAt: new Date(1546300800000).toISOString(), - last_modified: 1546300800000, - }, - { - id: 'a4c6a479-7cca-4d3b-ab90-45d3eaa957f3', - title: 'Check other examples', - completed: false, - createdAt: new Date(1548979200000).toISOString(), - last_modified: 1548979200000, - }, - { - id: 'a4c6a479-7cca-4d3b-bc10-45d3eaa957r5', - title: 'Use "@ngx-odm/rxdb" in your project', - completed: false, - createdAt: new Date().toISOString(), - last_modified: Date.now(), - }, - ], - filter: 'ALL', -}; diff --git a/examples/demo/src/app/todos/models/todos.schema.ts b/examples/demo/src/app/todos/models/todos.schema.ts deleted file mode 100644 index 6eb648e..0000000 --- a/examples/demo/src/app/todos/models/todos.schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RxCollectionCreatorExtended } from '@ngx-odm/rxdb/config'; -import type { RxCollection } from 'rxdb'; -import { TODOS_INITIAL_STATE } from './todos.model'; - -export async function percentageCompletedFn() { - const allDocs = await (this as RxCollection).find().exec(); - return allDocs.filter(doc => !!doc.completed).length / allDocs.length; -} -const collectionMethods = { - percentageCompleted: percentageCompletedFn, -}; - -export const TODOS_COLLECTION_CONFIG: RxCollectionCreatorExtended = { - name: 'todo', - localDocuments: true, - statics: collectionMethods, - schema: null, - options: { - schemaUrl: 'assets/data/todo.schema.json', - initialDocs: TODOS_INITIAL_STATE.items, - }, -}; diff --git a/examples/demo/src/app/todos/services/index.ts b/examples/demo/src/app/todos/services/index.ts deleted file mode 100644 index bfdb6b0..0000000 --- a/examples/demo/src/app/todos/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// start:ng42.barrel -export * from './todos.service'; -// end:ng42.barrel diff --git a/examples/demo/src/app/todos/services/todos.service.ts b/examples/demo/src/app/todos/services/todos.service.ts deleted file mode 100755 index ab7a5cd..0000000 --- a/examples/demo/src/app/todos/services/todos.service.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable no-console */ -import { Location } from '@angular/common'; -import { Injectable, inject } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { NgxRxdbCollectionService, NgxRxdbCollection } from '@ngx-odm/rxdb/collection'; -import type { RxDatabaseCreator } from 'rxdb'; -import { - Observable, - defaultIfEmpty, - distinctUntilChanged, - of, - startWith, - switchMap, - tap, -} from 'rxjs'; -import { v4 as uuid } from 'uuid'; -import { Todo, TodosFilter } from '../models'; - -@Injectable() -export class TodosService { - private collectionService: NgxRxdbCollection = inject>( - NgxRxdbCollectionService - ); - private location = inject(Location); - private title = inject(Title); - newTodo = ''; - isEditing = ''; - - filter$: Observable = this.collectionService - .getLocal('local', 'filterValue') - .pipe( - startWith('ALL'), - distinctUntilChanged(), - defaultIfEmpty('ALL') - ) as Observable; - - count$ = this.collectionService.count().pipe(defaultIfEmpty(0)); - - todos$: Observable = of([]).pipe( - switchMap(() => this.collectionService.docs()), - tap(docs => { - const total = docs.length; - const remaining = docs.filter(doc => !doc.completed).length; - this.title.setTitle(`(${total - remaining}/${total}) Todos done`); - }), - defaultIfEmpty([]) - ); - - get dbOptions(): Readonly { - return this.collectionService.dbOptions; - } - - get isAddTodoDisabled() { - return this.newTodo.length < 4; - } - - newTodoChange(newTodo: string) { - this.newTodo = newTodo; - } - - newTodoClear() { - this.newTodo = ''; - } - - editTodo(todo: Todo, elm: HTMLInputElement) { - this.isEditing = todo.id; - setTimeout(() => { - elm.focus(); - }, 0); - } - - stopEditing() { - this.isEditing = ''; - } - - add(): void { - if (this.isAddTodoDisabled) { - return; - } - const id = uuid(); - const payload: Todo = { - id, - title: this.newTodo.trim(), - completed: false, - createdAt: new Date().toISOString(), - last_modified: undefined, - }; - this.collectionService.insert(payload); - this.newTodo = ''; - } - - updateEditingTodo(todo: Todo, title: string): void { - this.collectionService.set(todo.id, { title }); - } - - toggle(todo: Todo): void { - this.collectionService.set(todo.id, { completed: !todo.completed }); - } - - toggleAllTodos(completed: boolean) { - this.collectionService.updateBulk( - { selector: { completed: { $eq: !completed } } }, - { completed } - ); - } - - removeTodo(todo: Todo): void { - this.collectionService.remove(todo.id); - } - - removeCompletedTodos(): void { - this.collectionService.removeBulk({ selector: { completed: true } }); - this.filterTodos('ALL'); - } - - restoreFilter(): void { - const query = this.location.path().split('?')[1]; - const searchParams = new URLSearchParams(query); - const filterValue = searchParams.get('filter') || 'ALL'; - this.collectionService.upsertLocal('local', { filterValue }); - } - - filterTodos(filterValue: TodosFilter): void { - const path = this.location.path().split('?')[0]; - this.location.replaceState(path, `filter=${filterValue}`); - this.collectionService.upsertLocal('local', { filterValue }); - } -} diff --git a/examples/demo/src/app/todos/todos-routing.module.ts b/examples/demo/src/app/todos/todos-routing.module.ts deleted file mode 100644 index 6c16ccb..0000000 --- a/examples/demo/src/app/todos/todos-routing.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { TodosComponent } from './components/todos/todos.component'; - -const routes: Routes = [ - { - path: '', - children: [ - { - path: '', - component: TodosComponent, - }, - ], - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class TodosRoutingModule {} diff --git a/examples/demo/src/app/todos/todos.component.css b/examples/demo/src/app/todos/todos.component.css new file mode 100644 index 0000000..35ab6ae --- /dev/null +++ b/examples/demo/src/app/todos/todos.component.css @@ -0,0 +1,19 @@ +.clear-completed:disabled { + color: #999; + cursor: not-allowed; + text-decoration: none; +} + +.todo-list li label+.last-modified { + position: absolute; + bottom: 4px; + right: 24px; + display: none; + font-size: small; + color: #999; + text-decoration: none !important; +} + +.todo-list li:hover label:not(.editing)+.last-modified { + display: block; +} diff --git a/examples/demo/src/app/todos/components/todos/todos.component.html b/examples/demo/src/app/todos/todos.component.html similarity index 72% rename from examples/demo/src/app/todos/components/todos/todos.component.html rename to examples/demo/src/app/todos/todos.component.html index 2359fb8..7fa6d45 100644 --- a/examples/demo/src/app/todos/components/todos/todos.component.html +++ b/examples/demo/src/app/todos/todos.component.html @@ -1,4 +1,3 @@ -
@@ -33,30 +31,27 @@

todos

  • -
    -
  • @@ -103,11 +98,11 @@

    todos

    diff --git a/examples/demo/src/app/todos/components/todos/todos.component.ts b/examples/demo/src/app/todos/todos.component.ts similarity index 60% rename from examples/demo/src/app/todos/components/todos/todos.component.ts rename to examples/demo/src/app/todos/todos.component.ts index 099eec0..31973bf 100644 --- a/examples/demo/src/app/todos/components/todos/todos.component.ts +++ b/examples/demo/src/app/todos/todos.component.ts @@ -1,14 +1,9 @@ import { animate, query, stagger, style, transition, trigger } from '@angular/animations'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - OnInit, - inject, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Title } from '@angular/platform-browser'; import { Observable, tap } from 'rxjs'; -import { Todo } from '../../models'; -import { TodosService } from '../../services'; +import { Todo } from './todos.model'; +import { TodosService } from './todos.service'; const listAnimation = trigger('listAnimation', [ transition('* <=> *', [ @@ -33,22 +28,21 @@ const listAnimation = trigger('listAnimation', [ changeDetection: ChangeDetectionStrategy.OnPush, animations: [listAnimation], }) -export class TodosComponent implements OnInit { - private cdRef = inject(ChangeDetectorRef); +export class TodosComponent { + private title = inject(Title); readonly todosService = inject(TodosService); + filter$ = this.todosService.filter$; todos$: Observable = this.todosService.todos$.pipe( - tap(() => { - // INFO: for `multiinstance` (multiple tabs) case - need to force change detection - if (this.todosService.dbOptions.multiInstance) { - this.cdRef.detectChanges(); - } + tap(docs => { + const total = docs.length; + const remaining = docs.filter(doc => !doc.completed).length; + this.title.setTitle(`(${total - remaining}/${total}) Todos done`); }) ); count$ = this.todosService.count$; - trackByFn = (index: number, item: Todo) => item.id; - ngOnInit() { - this.todosService.restoreFilter(); - } + trackByFn = (index: number, item: Todo) => { + return item.last_modified; + }; } diff --git a/examples/demo/src/app/todos/todos.config.ts b/examples/demo/src/app/todos/todos.config.ts new file mode 100644 index 0000000..a09c8b1 --- /dev/null +++ b/examples/demo/src/app/todos/todos.config.ts @@ -0,0 +1,43 @@ +import type { RxCollectionCreatorExtended } from '@ngx-odm/rxdb/config'; +import { conflictHandlerKinto } from '@ngx-odm/rxdb/replication-kinto'; +import type { RxCollection } from 'rxdb'; +import { TODOS_INITIAL_ITEMS } from './todos.model'; + +export async function percentageCompletedFn() { + const allDocs = await (this as RxCollection).find().exec(); + return allDocs.filter(doc => !!doc.completed).length / allDocs.length; +} +const collectionMethods = { + percentageCompleted: percentageCompletedFn, +}; + +export const TODOS_COLLECTION_CONFIG: RxCollectionCreatorExtended = { + name: 'todo', + localDocuments: true, + schema: undefined, // to load schema from remote url pass `undefined` here + options: { + schemaUrl: 'assets/data/todo.schema.json', // load schema from remote url + initialDocs: TODOS_INITIAL_ITEMS, // populate collection with initial data, + persistLocalToURL: true, // bind `local` doc data to URL query params + }, + statics: collectionMethods, + // in this example we have 3 migrations, since the beginning of development + migrationStrategies: { + 1: function (doc) { + if (doc._deleted) { + return null; + } + doc.last_modified = new Date(doc.createdAt).getTime(); // string to unix + return doc; + }, + 2: function (doc) { + if (doc._deleted) { + return null; + } + doc.createdAt = new Date(doc.createdAt).toISOString(); // to string + return doc; + }, + 3: d => d, + }, + conflictHandler: conflictHandlerKinto, // don't need custom for CouchDb example +}; diff --git a/examples/demo/src/app/todos/todos.model.ts b/examples/demo/src/app/todos/todos.model.ts new file mode 100755 index 0000000..314a659 --- /dev/null +++ b/examples/demo/src/app/todos/todos.model.ts @@ -0,0 +1,37 @@ +export interface Todo { + id: string; + title: string; + completed: boolean; + createdAt: string; + last_modified: number; +} + +export type TodosFilter = 'ALL' | 'COMPLETED' | 'ACTIVE'; + +export interface TodosLocalState { + filter: TodosFilter; +} + +export const TODOS_INITIAL_ITEMS = [ + { + id: 'ac3ef2c6-c98b-43e1-9047-71d68b1f92f4', + title: 'Open Todo list example', + completed: true, + createdAt: new Date(1546300800000).toISOString(), + last_modified: 1546300800000, + }, + { + id: 'a4c6a479-7cca-4d3b-ab90-45d3eaa957f3', + title: 'Check other examples', + completed: false, + createdAt: new Date(1548979200000).toISOString(), + last_modified: 1548979200000, + }, + { + id: 'a4c6a479-7cca-4d3b-bc10-45d3eaa957r5', + title: 'Use "@ngx-odm/rxdb" in your project', + completed: false, + createdAt: new Date().toISOString(), + last_modified: Date.now(), + }, +]; diff --git a/examples/demo/src/app/todos/todos.module.ts b/examples/demo/src/app/todos/todos.module.ts index 2ad14f1..6aac2eb 100644 --- a/examples/demo/src/app/todos/todos.module.ts +++ b/examples/demo/src/app/todos/todos.module.ts @@ -1,116 +1,27 @@ import { CommonModule } from '@angular/common'; import { Inject, NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; import { LetDirective, PushPipe } from '@ngrx/component'; import { NgxRxdbModule } from '@ngx-odm/rxdb'; import { NgxRxdbCollection, NgxRxdbCollectionService } from '@ngx-odm/rxdb/collection'; -import { conflictHandlerKinto, replicateKintoDB } from '@ngx-odm/rxdb/replication-kinto'; -import { getDefaultFetchWithHeaders } from '@ngx-odm/rxdb/utils'; -import { b64EncodeUnicode } from 'rxdb'; -import { RxReplicationState } from 'rxdb/plugins/replication'; -import { replicateCouchDB } from 'rxdb/plugins/replication-couchdb'; -import { environment } from '../../environments/environment'; -import { TodosComponent } from './components/todos/todos.component'; -import { TodosPipe } from './components/todos/todos.pipe'; -import { TODOS_INITIAL_STATE, Todo } from './models'; -import { TodosService } from './services'; -import { TodosRoutingModule } from './todos-routing.module'; +import { TodosComponent } from './todos.component'; +import { TODOS_COLLECTION_CONFIG } from './todos.config'; +import { Todo } from './todos.model'; +import { TodosPipe } from './todos.pipe'; +import { todosReplicationStateFactory } from './todos.replication'; +import { TodosService } from './todos.service'; + +TODOS_COLLECTION_CONFIG.options.replicationStateFactory = todosReplicationStateFactory; @NgModule({ imports: [ + RouterModule.forChild([{ path: '', component: TodosComponent }]), CommonModule, FormsModule, LetDirective, PushPipe, - TodosRoutingModule, - NgxRxdbModule.forFeature({ - name: 'todo', - localDocuments: true, - schema: undefined, // to load schema from remote url pass `undefined` here - options: { - schemaUrl: 'assets/data/todo.schema.json', // load schema from remote url - initialDocs: TODOS_INITIAL_STATE.items, // populate collection with initial data, - recreate: true, - replicationStateFactory: collection => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let replicationState: RxReplicationState | null = null; - - switch (localStorage['_ngx_rxdb_replication']) { - case 'kinto': { - replicationState = replicateKintoDB({ - replicationIdentifier: 'demo-kinto-replication:todo', - collection, - kintoSyncOptions: { - remote: environment.kintoServer, - bucket: environment.bucket, - collection: environment.collection, - }, - fetch: getDefaultFetchWithHeaders({ - Authorization: 'Basic ' + b64EncodeUnicode('admin:adminadmin'), - }), - retryTime: 15000, - live: true, - autoStart: true, - pull: { - batchSize: 60, - modifier: d => d, - heartbeat: 60000, - }, - push: { - modifier: d => d, - }, - }); - break; - } - case 'couchdb': { - replicationState = replicateCouchDB({ - replicationIdentifier: 'demo-couchdb-replication', - collection, - fetch: getDefaultFetchWithHeaders({ - Authorization: 'Basic ' + b64EncodeUnicode('admin:adminadmin'), - }), - url: 'http://localhost:5984/demo/', - retryTime: 15000, - live: true, - pull: { - batchSize: 60, - modifier: d => d, - heartbeat: 60000, - }, - push: { - modifier: d => d, - }, - }); - break; - } - default: { - break; - } - } - - return replicationState; - }, - }, - autoMigrate: true, - migrationStrategies: { - 1: function (doc) { - if (doc._deleted) { - return null; - } - doc.last_modified = new Date(doc.createdAt).getTime(); // string to unix - return doc; - }, - 2: function (doc) { - if (doc._deleted) { - return null; - } - doc.createdAt = new Date(doc.createdAt).toISOString(); // to string - return doc; - }, - 3: d => d, - }, - conflictHandler: conflictHandlerKinto, - }), + NgxRxdbModule.forFeature(TODOS_COLLECTION_CONFIG), // creates RxDB collection from config ], declarations: [TodosComponent, TodosPipe], providers: [TodosService], diff --git a/examples/demo/src/app/todos/components/todos/todos.pipe.ts b/examples/demo/src/app/todos/todos.pipe.ts similarity index 79% rename from examples/demo/src/app/todos/components/todos/todos.pipe.ts rename to examples/demo/src/app/todos/todos.pipe.ts index b878024..e41ae4e 100644 --- a/examples/demo/src/app/todos/components/todos/todos.pipe.ts +++ b/examples/demo/src/app/todos/todos.pipe.ts @@ -1,7 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { Todo, TodosFilter } from '../../models'; +import { Todo, TodosFilter } from './todos.model'; -@Pipe({ name: 'byStatus', pure: false }) +@Pipe({ name: 'byStatus' }) export class TodosPipe implements PipeTransform { transform(value: Todo[], status: TodosFilter): Todo[] { if (!value) { diff --git a/examples/demo/src/app/todos/todos.replication.ts b/examples/demo/src/app/todos/todos.replication.ts new file mode 100644 index 0000000..61d8e39 --- /dev/null +++ b/examples/demo/src/app/todos/todos.replication.ts @@ -0,0 +1,67 @@ +import { replicateKintoDB } from '@ngx-odm/rxdb/replication-kinto'; +import { getDefaultFetchWithHeaders } from '@ngx-odm/rxdb/utils'; +import { b64EncodeUnicode } from 'rxdb'; +import { RxReplicationState } from 'rxdb/plugins/replication'; +import { replicateCouchDB } from 'rxdb/plugins/replication-couchdb'; +import { environment } from '../../environments/environment'; +import { Todo } from './todos.model'; + +export const todosReplicationStateFactory = collection => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let replicationState: RxReplicationState | null = null; + + switch (localStorage['_ngx_rxdb_replication']) { + case 'kinto': { + replicationState = replicateKintoDB({ + replicationIdentifier: 'demo-kinto-replication:todo', + collection, + kintoSyncOptions: { + remote: environment.kintoServer, + bucket: environment.bucket, + collection: environment.collection, + }, + fetch: getDefaultFetchWithHeaders({ + Authorization: 'Basic ' + b64EncodeUnicode('admin:adminadmin'), + }), + retryTime: 15000, + live: true, + autoStart: true, + pull: { + batchSize: 60, + modifier: d => d, + heartbeat: 60000, + }, + push: { + modifier: d => d, + }, + }); + break; + } + case 'couchdb': { + replicationState = replicateCouchDB({ + replicationIdentifier: 'demo-couchdb-replication', + collection, + fetch: getDefaultFetchWithHeaders({ + Authorization: 'Basic ' + b64EncodeUnicode('admin:adminadmin'), + }), + url: 'http://localhost:5984/demo/', + retryTime: 15000, + live: true, + pull: { + batchSize: 60, + modifier: d => d, + heartbeat: 60000, + }, + push: { + modifier: d => d, + }, + }); + break; + } + default: { + break; + } + } + + return replicationState; +}; diff --git a/examples/demo/src/app/todos/todos.service.ts b/examples/demo/src/app/todos/todos.service.ts new file mode 100755 index 0000000..80003d7 --- /dev/null +++ b/examples/demo/src/app/todos/todos.service.ts @@ -0,0 +1,121 @@ +/* eslint-disable no-console */ +import { Injectable, inject } from '@angular/core'; +import { + DEFAULT_LOCAL_DOCUMENT_ID, + NgxRxdbCollection, + NgxRxdbCollectionService, +} from '@ngx-odm/rxdb/collection'; +import type { RxDatabaseCreator } from 'rxdb'; +import { Observable, distinctUntilChanged, startWith } from 'rxjs'; +import { v4 as uuid } from 'uuid'; +import { Todo, TodosFilter, TodosLocalState } from './todos.model'; + +@Injectable() +export class TodosService { + private collectionService: NgxRxdbCollection = inject>( + NgxRxdbCollectionService + ); + newTodo = ''; + current: Todo = undefined; + + filter$: Observable = this.collectionService + .getLocal$(DEFAULT_LOCAL_DOCUMENT_ID, 'filter') + .pipe(startWith('ALL'), distinctUntilChanged()) as Observable; + + count$ = this.collectionService.count(); + + todos$: Observable = this.collectionService.docs(); + + get dbOptions(): Readonly { + return this.collectionService.dbOptions; + } + + get isAddTodoDisabled() { + return this.newTodo.length < 4; + } + + constructor() { + this.collectionService.addHook('postSave', function (plainData, rxDocument) { + console.log('postSave', plainData, rxDocument); + return new Promise(res => setTimeout(res, 100)); + }); + this.restoreFilter(); + } + + addTodo(): void { + if (this.isAddTodoDisabled) { + return; + } + const id = uuid(); + const payload: Todo = { + id, + title: this.newTodo.trim(), + completed: false, + createdAt: new Date().toISOString(), + last_modified: undefined, + }; + this.collectionService.insert(payload); + this.newTodo = ''; + } + + setEditinigTodo(todo: Todo, event: Event, isEditing: boolean) { + const elm = event.target as HTMLElement; + if (isEditing) { + elm.contentEditable = 'plaintext-only'; + elm.focus(); + this.current = todo; + } else { + elm.contentEditable = 'false'; + elm.innerText = todo.title; + this.current = undefined; + } + } + + updateEditingTodo(todo: Todo, event: Event) { + event.preventDefault(); + const elm = event.target as HTMLElement; + const payload = { + title: elm.innerText.trim(), + last_modified: Date.now(), + } as Todo; + this.collectionService.set(todo.id, payload); + this.setEditinigTodo(payload, event, false); + this.current = undefined; + } + + toggleTodo(todo: Todo): void { + const payload = { + completed: !todo.completed, + last_modified: Date.now(), + }; + this.collectionService.set(todo.id, payload); + } + + toggleAllTodos(completed: boolean) { + this.collectionService.updateBulk( + { selector: { completed: { $eq: !completed } } }, + { completed } + ); + } + + removeTodo(todo: Todo): void { + this.collectionService.remove(todo.id); + } + + removeCompletedTodos(): void { + this.collectionService.removeBulk({ selector: { completed: true } }); + this.filterTodos('ALL'); + } + + restoreFilter(): void { + this.collectionService.restoreLocalFromURL(DEFAULT_LOCAL_DOCUMENT_ID); + } + + filterTodos(filter: TodosFilter): void { + this.collectionService.setLocal( + DEFAULT_LOCAL_DOCUMENT_ID, + 'filter', + filter + ); + } +} diff --git a/examples/screencast.gif b/examples/screencast.gif new file mode 100644 index 0000000..5768c79 Binary files /dev/null and b/examples/screencast.gif differ diff --git a/examples/standalone/src/app/app.component.ts b/examples/standalone/src/app/app.component.ts index 089b55d..47a29c7 100644 --- a/examples/standalone/src/app/app.component.ts +++ b/examples/standalone/src/app/app.component.ts @@ -1,7 +1,8 @@ -import { ApplicationRef, Component, inject } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterModule } from '@angular/router'; -import { filter, tap } from 'rxjs'; +import { RenderScheduler } from '@ngrx/component'; +import { filter } from 'rxjs'; @Component({ standalone: true, @@ -10,10 +11,12 @@ import { filter, tap } from 'rxjs'; template: ` `, + providers: [RenderScheduler], }) export class AppComponent { private router = inject(Router); - private appRef = inject(ApplicationRef); + private renderScheduler = inject(RenderScheduler); + constructor() { this.zonelessCD(); } @@ -22,11 +25,8 @@ export class AppComponent { this.router.events .pipe( filter(event => event instanceof NavigationEnd), - tap(() => { - this.appRef.tick(); - }), takeUntilDestroyed() ) - .subscribe(); + .subscribe(() => this.renderScheduler.schedule()); } } diff --git a/examples/standalone/src/app/todos/todos.component.ts b/examples/standalone/src/app/todos/todos.component.ts index d662c2b..2b905c1 100644 --- a/examples/standalone/src/app/todos/todos.component.ts +++ b/examples/standalone/src/app/todos/todos.component.ts @@ -1,13 +1,8 @@ import { animate, query, stagger, style, transition, trigger } from '@angular/animations'; import { CommonModule } from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - effect, - inject, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; import { Title } from '@angular/platform-browser'; +import { RenderScheduler } from '@ngrx/component'; import { NgxRxdbUtils } from '@ngx-odm/rxdb/utils'; import { Todo } from './todos.model'; import { TodoStore } from './todos.store'; @@ -36,13 +31,12 @@ const listAnimation = trigger('listAnimation', [ changeDetection: ChangeDetectionStrategy.OnPush, animations: [listAnimation], imports: [CommonModule], - providers: [TodoStore], + providers: [RenderScheduler, TodoStore], }) export class TodosComponent { - private cdRef = inject(ChangeDetectorRef); + private renderScheduler = inject(RenderScheduler); private titleService = inject(Title); readonly todoStore = inject(TodoStore); - manualCD = true; // INFO: Angular 17 doesn't provide way to detect changes with `signals` ONLY and no zone trackByFn = (index: number, item: Todo) => { return item.last_modified; @@ -53,11 +47,11 @@ export class TodosComponent { const { title, filter, entities } = this.todoStore; const titleString = title(); this.titleService.setTitle(titleString); - NgxRxdbUtils.logger.log(filter()); // INFO: signals on their own do not work if we do not use it directly here to trigger CD - NgxRxdbUtils.logger.table(entities()); // INFO: signals on their own do not work if we do not use it directly here to trigger CD - if (this.manualCD) { - this.cdRef.detectChanges(); - } + NgxRxdbUtils.logger.log('filter:', filter()); // INFO: signals on their own do not work if we do not use it directly here with proper dependency + NgxRxdbUtils.logger.table(entities()); // INFO: signals on their own do not work if we do not use it directly here with proper dependency + + // INFO: Angular 17 doesn't provide way to detect changes with `signals` ONLY and no zone + this.renderScheduler.schedule(); }); } } diff --git a/examples/standalone/src/app/todos/todos.config.ts b/examples/standalone/src/app/todos/todos.config.ts index a1e8f7b..261b3b5 100644 --- a/examples/standalone/src/app/todos/todos.config.ts +++ b/examples/standalone/src/app/todos/todos.config.ts @@ -14,7 +14,7 @@ export const TodosCollectionConfig: RxCollectionCreatorExtended = { options: { schemaUrl: 'assets/data/todo.schema.json', // load schema from remote url initialDocs: TODOS_INITIAL_STATE.items, // populate collection with initial data, - recreate: true, + persistLocalToURL: true, replicationStateFactory: collection => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let replicationState: RxReplicationState | null = null; diff --git a/examples/standalone/src/app/todos/todos.store.ts b/examples/standalone/src/app/todos/todos.store.ts index 8b1ca3c..7226713 100644 --- a/examples/standalone/src/app/todos/todos.store.ts +++ b/examples/standalone/src/app/todos/todos.store.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Location } from '@angular/common'; -import { computed, inject } from '@angular/core'; +import { computed } from '@angular/core'; import { withDevtools } from '@angular-architects/ngrx-toolkit'; import { patchState, @@ -57,7 +56,6 @@ export const TodoStore = signalStore( }; }), withMethods(store => { - const location = inject(Location); return { newTodoChange(newTodo: string) { patchState(store, { newTodo }); @@ -80,7 +78,6 @@ export const TodoStore = signalStore( store.insert(payload); }, setEditinigTodo(todo: Todo, event: Event, isEditing: boolean) { - const current = store.current(); const elm = event.target as HTMLElement; if (isEditing) { elm.contentEditable = 'plaintext-only'; @@ -88,7 +85,7 @@ export const TodoStore = signalStore( store.setCurrent(todo); } else { elm.contentEditable = 'false'; - elm.innerText = current.title; + elm.innerText = todo.title; store.setCurrent(undefined); } }, @@ -101,7 +98,7 @@ export const TodoStore = signalStore( last_modified: Date.now(), }; store.update(payload); - this.setEditinigTodo({}, event, false); + this.setEditinigTodo(payload, event, false); }, toggleTodo(todo: Todo) { const payload: Todo = { @@ -121,19 +118,15 @@ export const TodoStore = signalStore( store.removeAllBy({ selector: { completed: { $eq: true } } }); }, filterTodos(filter: TodosFilter): void { - const path = location.path().split('?')[0]; - location.replaceState(path, `filter=${filter}`); store.updateFilter(filter); }, }; }), withHooks({ /** On init update filter from URL and fetch documents from RxDb */ - onInit: ({ findAllDocs, filter, updateFilter }) => { + onInit: ({ findAllDocs, filter, restoreFilter }) => { const query: MangoQuery = { selector: {}, sort: [{ createdAt: 'desc' }] }; - const params = location.search.split('?')[1]; - const searchParams = new URLSearchParams(params); - updateFilter((searchParams.get('filter') as TodosFilter) || filter()); + restoreFilter(); findAllDocs(query); }, }) diff --git a/package-lock.json b/package-lock.json index bf164b4..ca5b387 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "compare-versions": "^6.1.0", "normalize.css": "8.0.1", "reflect-metadata": "0.1.13", - "rxdb": "^15.0.0", + "rxdb": "^15.3.0", "rxjs": "7.8.1", "todomvc-app-css": "2.4.3", "todomvc-common": "1.0.5", @@ -14950,12 +14950,9 @@ } }, "node_modules/dexie": { - "version": "4.0.0-alpha.4", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.0-alpha.4.tgz", - "integrity": "sha512-xYABeT5ctG7WAJtyr/6gKhWXD6NY8ydZieQlvLQkFV6KXY33I+ShE39pToGzQw+3/xs760rW+opxhoVZ/yzVxQ==", - "engines": { - "node": ">=6.0" - } + "version": "4.0.1-beta.6", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.1-beta.6.tgz", + "integrity": "sha512-NOexH4Rn8mrdSg/bn3YNFdPzQ1vPNxgPYLGWGU46z26NYGW1XmC0hhjjttwx9jYwba1K9Ypo1ZbZLNKtK6INSg==" }, "node_modules/dfa": { "version": "1.2.0", @@ -28111,12 +28108,12 @@ "integrity": "sha512-CiaiuN6gapkdl+cZUr67W6I8jquN4lkak3vtIsIWCl4XIPP8ffsoyN6/+PuGXnQy8Cu8W2y9Xxh31Rq4M6wUug==" }, "node_modules/rxdb": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/rxdb/-/rxdb-15.0.0.tgz", - "integrity": "sha512-0UbEwul0VDWa+GuwdyKDqv2IVdQoU2r5VXosnGNMzTwDtb7XtC0fRbJIpDveOTeoDbTI0qRJqVDuQcp6NhHtRA==", + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/rxdb/-/rxdb-15.3.0.tgz", + "integrity": "sha512-T8Q5i5KsX5ovQ+ti+RwX8JN3l3aGYVLNB4tKTUtKaX167POgKgt4mm+liBH6lIwRckBlKcIWdBuTPp6/q/L+IQ==", "hasInstallScript": true, "dependencies": { - "@babel/runtime": "7.23.6", + "@babel/runtime": "7.23.8", "@types/clone": "2.1.4", "@types/cors": "2.8.17", "@types/express": "4.17.21", @@ -28128,7 +28125,7 @@ "broadcast-channel": "7.0.0", "crypto-js": "4.2.0", "custom-idle-queue": "3.0.1", - "dexie": "4.0.0-alpha.4", + "dexie": "4.0.1-beta.6", "event-reduce-js": "5.2.7", "firebase": "10.7.1", "get-graphql-from-jsonschema": "8.1.0", @@ -28148,7 +28145,7 @@ "simple-peer": "9.11.1", "unload": "2.4.1", "util": "0.12.5", - "ws": "8.15.1", + "ws": "8.16.0", "z-schema": "6.0.1" }, "engines": { @@ -28159,9 +28156,9 @@ } }, "node_modules/rxdb/node_modules/@babel/runtime": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", - "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", + "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -28175,9 +28172,9 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/rxdb/node_modules/ws": { - "version": "8.15.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.15.1.tgz", - "integrity": "sha512-W5OZiCjXEmk0yZ66ZN82beM5Sz7l7coYxpRkzS+p9PP+ToQry8szKh+61eNktr7EA9DOwvFGhfC605jDHbP6QQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 40a90df..72e68a2 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "compare-versions": "^6.1.0", "normalize.css": "8.0.1", "reflect-metadata": "0.1.13", - "rxdb": "^15.0.0", + "rxdb": "^15.3.0", "rxjs": "7.8.1", "todomvc-app-css": "2.4.3", "todomvc-common": "1.0.5", diff --git a/packages/rxdb/README.md b/packages/rxdb/README.md index 5381b08..4eb6068 100644 --- a/packages/rxdb/README.md +++ b/packages/rxdb/README.md @@ -1,10 +1,10 @@ # @ngx-odm/rxdb -> Angular 10+ wrapper for **RxDB** - A realtime Database for the Web +> Angular 14+ wrapper for **RxDB** - A realtime Database for the Web ## Demo -![Example screenshot](examples/demo/src/assets/images/screenshot.png) +![Example Screencast](screencast.gif) [demo](https://voznik.github.io/ngx-odm/) - based on TodoMVC @@ -36,7 +36,7 @@ If you don't want to setup RxDB manually in your next Angular project - just imp ## Technologies -| RxDB | Angular 10+ | +| RxDB | Angular 14+ | | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | [![RxDB](https://cdn.rawgit.com/pubkey/rxdb/ba7c9b80/docs/files/logo/logo_text.svg)](https://rxdb.info/) | [![Angular](https://angular.io/assets/images/logos/angular/angular.svg)](https://angular.io/) | @@ -149,7 +149,7 @@ export class TodosService { // remove many dcouments by qeury removeCompletedTodos(): void { - cthis.collectionService.removeBulk({ selector: { completed: true } }); + this.collectionService.removeBulk({ selector: { completed: true } }); } // ... } diff --git a/packages/rxdb/collection/src/lib/rxdb-collection.service.spec.ts b/packages/rxdb/collection/src/lib/rxdb-collection.service.spec.ts index 79fb3ba..addaf19 100644 --- a/packages/rxdb/collection/src/lib/rxdb-collection.service.spec.ts +++ b/packages/rxdb/collection/src/lib/rxdb-collection.service.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +import { TestBed } from '@angular/core/testing'; import { NgxRxdbService } from '@ngx-odm/rxdb/core'; import { TEST_FEATURE_CONFIG_1, @@ -8,9 +9,12 @@ import { import { MangoQuery, RxQuery } from 'rxdb'; import { createRxLocalDocument } from 'rxdb/plugins/local-documents'; import { RxReplicationState } from 'rxdb/plugins/replication'; -import { flatClone } from 'rxdb/plugins/utils'; import { EMPTY, Observable, Subject, firstValueFrom } from 'rxjs'; -import { NgxRxdbCollection } from './rxdb-collection.service'; +import { + NgxRxdbCollection, + NgxRxdbCollectionService, + collectionServiceFactory, +} from './rxdb-collection.service'; const getMockReplicationState = (obj: Partial>) => { obj.reSync = jest.fn(); @@ -27,7 +31,16 @@ describe(`NgxRxdbCollectionService`, () => { beforeEach(async () => { dbService = await getMockRxdbService(); - service = new NgxRxdbCollection(dbService, flatClone(TEST_FEATURE_CONFIG_1)); + TestBed.configureTestingModule({ + providers: [ + { provide: NgxRxdbService, useValue: dbService }, + { + provide: NgxRxdbCollectionService, + useFactory: collectionServiceFactory(TEST_FEATURE_CONFIG_1), + }, + ], + }); + service = TestBed.inject(NgxRxdbCollectionService) as any; }); afterEach(() => { @@ -273,9 +286,15 @@ describe(`NgxRxdbCollectionService`, () => { expect(service.collection.bulkRemove).toHaveBeenCalledWith(['11', '22']); }); - it('should get local doc', async () => { + it('should get local doc async', async () => { + const id = '1'; + await service.getLocal(id); + expect(service.collection.getLocal).toHaveBeenCalledWith(id); + }); + + it('should get local doc as observable', async () => { const id = '1'; - await firstValueFrom(service.getLocal(id)); + await firstValueFrom(service.getLocal$(id)); expect(service.collection.getLocal$).toHaveBeenCalledWith(id); }); @@ -297,40 +316,33 @@ describe(`NgxRxdbCollectionService`, () => { const id = '1'; const prop = 'foo'; const value = 'bar'; - const mockLocalDoc = createRxLocalDocument({ id: '0', data: {} } as any, {}); - jest.spyOn(mockLocalDoc, 'update').mockResolvedValue(null as any); - jest.spyOn(service.collection, 'getLocal').mockResolvedValueOnce(null); - const result = await service.setLocal(id, prop, value); - expect(service.collection.getLocal).toHaveBeenCalledWith(id); - expect(mockLocalDoc.update).not.toHaveBeenCalled(); - expect(result).toBeNull(); + const mockLocalDoc = createRxLocalDocument( + { id: '1', data: { [prop]: 'empty' } } as any, + {} + ); + jest.spyOn(service.collection, 'getLocal').mockResolvedValueOnce(mockLocalDoc); + await service.setLocal(id, prop, value); + expect(service.collection.upsertLocal).toHaveBeenCalledWith(id, { [prop]: value }); + const result = await service.getLocal(id); + expect(result).toEqual({ [prop]: value }); }); it('should not update local doc if not found', async () => { const id = '1'; const prop = 'name'; const value = 'updated'; - const mockLocalDoc = createRxLocalDocument( - { id: '1', data: { [prop]: 'empty' } } as any, - {} - ); - jest - .spyOn(mockLocalDoc, 'update') - .mockResolvedValue( - createRxLocalDocument({ id: '1', data: { [prop]: value } } as any, {}) as any - ); - jest.spyOn(service.collection, 'getLocal').mockResolvedValueOnce(mockLocalDoc); - const result = await service.setLocal(id, prop, value); - expect(service.collection.getLocal).toHaveBeenCalledWith(id); - expect(mockLocalDoc.update).toHaveBeenCalled(); - const { data } = result!.toJSON(); - expect(data).toEqual({ [prop]: value }); + jest.spyOn(service.collection, 'getLocal').mockResolvedValueOnce(null); + await service.setLocal(id, prop, value); + expect(service.collection.upsertLocal).not.toHaveBeenCalled(); }); it('should remove local doc', async () => { const id = '1'; + const mockLocalDoc = createRxLocalDocument({ id: '1', data: {} } as any, {}); + jest.spyOn(mockLocalDoc, 'remove').mockResolvedValueOnce(mockLocalDoc as any); + jest.spyOn(service.collection, 'getLocal').mockResolvedValueOnce(mockLocalDoc); await service.removeLocal(id); - expect(service.collection.getLocal).toHaveBeenCalledWith(id); + expect(mockLocalDoc.remove).toHaveBeenCalled(); }); }); }); diff --git a/packages/rxdb/collection/src/lib/rxdb-collection.service.ts b/packages/rxdb/collection/src/lib/rxdb-collection.service.ts index 7878fc5..03dee0c 100644 --- a/packages/rxdb/collection/src/lib/rxdb-collection.service.ts +++ b/packages/rxdb/collection/src/lib/rxdb-collection.service.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/ban-types */ -import { InjectionToken } from '@angular/core'; +import { Location } from '@angular/common'; +import { InjectionToken, NgZone, inject } from '@angular/core'; +import { Router } from '@angular/router'; import type { RxCollectionExtended as RxCollection, RxCollectionCreatorExtended, + RxCollectionHooks, RxDbMetadata, } from '@ngx-odm/rxdb/config'; import { NgxRxdbService } from '@ngx-odm/rxdb/core'; @@ -34,6 +37,8 @@ import { type EntityId = string; type Entity = { id: EntityId }; +export const DEFAULT_LOCAL_DOCUMENT_ID = 'local'; + /** * Injection token for Service for interacting with a RxDB {@link RxCollection}. * This token is used to inject an instance of NgxRxdbCollection into a component or service. @@ -48,14 +53,17 @@ export const NgxRxdbCollectionService = new InjectionToken( * @param config - The configuration object for the collection to be created automatically. */ export function collectionServiceFactory(config: RxCollectionCreatorExtended) { - return (dbService: NgxRxdbService): NgxRxdbCollection => - new NgxRxdbCollection(dbService, config); + return (): NgxRxdbCollection => new NgxRxdbCollection(config); } /** * Service for interacting with a RxDB {@link RxCollection}. */ export class NgxRxdbCollection { + protected readonly dbService: NgxRxdbService = inject(NgxRxdbService); + protected readonly ngZone: NgZone = inject(NgZone); + protected readonly location: Location = inject(Location); + protected readonly router: Router = inject(Router); private _collection!: RxCollection; private _replicationState: RxReplicationState | null = null; private _init$ = new ReplaySubject(); @@ -80,11 +88,8 @@ export class NgxRxdbCollection { return this._replicationState; } - constructor( - protected readonly dbService: NgxRxdbService, - public readonly config: RxCollectionCreatorExtended - ) { - this.init(dbService, config); + constructor(public readonly config: RxCollectionCreatorExtended) { + this.init(this.dbService, this.config); } /** @@ -173,6 +178,7 @@ export class NgxRxdbCollection { return this.initialized$.pipe( switchMap(() => this.collection.find(query).$), map((docs = []) => docs.map(d => d.toMutableJSON())), + NgxRxdbUtils.runInZone(this.ngZone), NgxRxdbUtils.debug('docs') ); } @@ -189,6 +195,7 @@ export class NgxRxdbCollection { return this.initialized$.pipe( switchMap(() => this.collection.findByIds(ids).$), map(result => [...result.values()].map(d => d.toMutableJSON())), + NgxRxdbUtils.runInZone(this.ngZone), NgxRxdbUtils.debug('docsByIds') ); } @@ -204,6 +211,7 @@ export class NgxRxdbCollection { merge(this.collection.insert$, this.collection.remove$).pipe(startWith(null)) ), switchMap(() => this.collection.count(query).exec()), + NgxRxdbUtils.runInZone(this.ngZone), NgxRxdbUtils.debug('count') ); } @@ -217,6 +225,7 @@ export class NgxRxdbCollection { return this.initialized$.pipe( switchMap(() => this.collection.findOne(id).$), map(doc => (doc ? doc.toMutableJSON() : null)), + NgxRxdbUtils.runInZone(this.ngZone), NgxRxdbUtils.debug('get one') ); } @@ -290,7 +299,7 @@ export class NgxRxdbCollection { */ async remove(entityOrId: T | string): Promise | null> { await this.ensureCollection(); - const id = typeof entityOrId === 'object' ? entityOrId['_id'] : entityOrId; + const id = NgxRxdbUtils.getMaybeId(entityOrId); return this.collection.findOne(id).remove(); } @@ -324,55 +333,118 @@ export class NgxRxdbCollection { return this.collection.remove(); } + /** + * Add one of RxDB-supported middleware-hooks to current collection, e.g run smth on document postSave. + * By default runs in series + * @param hook + * @param handler + * @param parralel + * @see https://rxdb.info/middleware.html + */ + async addHook( + hook: Hook, + handler: Parameters[Hook]>[0], + parralel = false + ): Promise { + await this.ensureCollection(); + // Type 'RxCollectionHookNoInstanceCallback' is not assignable to type 'RxCollectionHookCallback'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.collection[hook](handler as any, parralel); + } + // --------------------------------------------------------------------------- - // Local Documents @see https://rxdb.info/rx-local-document.html + // Local Documents wrappers @see https://rxdb.info/rx-local-document.html // --------------------------------------------------------------------------- + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-type-constraint */ - getLocal( - id: I, - key?: K - ): Observable : unknown> { + async getLocal(id: string, key?: string): Promise { + await this.ensureCollection(); + const doc = await this.collection.getLocal(id); + NgxRxdbUtils.logger.log('local document', doc); + if (!doc) { + return null; + } + return key ? doc?.get(key) : doc?.toJSON().data; + } + + getLocal$(id: string, key?: keyof L): Observable { return this.initialized$.pipe( - switchMap(() => this.collection.getLocal$(id)), + switchMap(() => this.collection.getLocal$(id)), map(doc => { if (!doc) { return null; } - return key ? doc.get(key) : doc; + return key ? doc.get(key as string) : doc.toJSON().data; }), - NgxRxdbUtils.debug('get local') + NgxRxdbUtils.runInZone(this.ngZone), + NgxRxdbUtils.debug('local document') ); } - async insertLocal(id: string, data: unknown): Promise> { + async insertLocal(id: string, data: L): Promise { await this.ensureCollection(); - return this.collection.insertLocal(id, data); + const doc = await this.collection.insertLocal(id, data); + await this.persistLocalToURL(doc); } - async upsertLocal(id: string, data: unknown): Promise> { + async upsertLocal(id: string, data: L): Promise { await this.ensureCollection(); - return this.collection.upsertLocal(id, data); + const doc = await this.collection.upsertLocal(id, data); + await this.persistLocalToURL(doc); } - async setLocal( - id: string, - prop: string, - value: unknown - ): Promise | null> { + /** + * @param id + * @param prop + * @param value + */ + async setLocal(id: string, prop: keyof L, value: unknown): Promise { await this.ensureCollection(); - const localDoc: RxLocalDocument | null = await this.collection.getLocal(id); - if (!localDoc || localDoc[prop] === value) { - return null; + const loc = await this.collection.getLocal(id); + if (!loc) { + return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return localDoc.update({ [prop]: value }) as Promise; + // INFO: as of RxDB version 15.3.0, local doc method `set` is missing + // so we update whole document + const doc = await this.collection.upsertLocal(id, { + ...loc?.toJSON().data, + [prop]: value, + } as L); + await this.persistLocalToURL(doc); } - async removeLocal(id: string): Promise { + async removeLocal(id: string): Promise { await this.ensureCollection(); - const localDoc: RxLocalDocument | null = await this.collection.getLocal(id); - return await localDoc?.remove(); + const doc: RxLocalDocument | null = await this.collection.getLocal(id); + await doc?.remove(); + await this.persistLocalToURL(doc); + } + + async persistLocalToURL(doc: RxLocalDocument | null): Promise { + if (!doc?.isLocal || !this.config.options?.persistLocalToURL) { + return; + } + const { data } = doc.toJSON(); + await this.router.navigate([], { + queryParams: NgxRxdbUtils.compactObject(data), + queryParamsHandling: 'merge', + replaceUrl: true, + }); + NgxRxdbUtils.logger.log('persistLocalToURL', data, this.router.url); + } + + async restoreLocalFromURL(id: string): Promise { + if (!this.config.options?.persistLocalToURL) { + return; + } + const data = this.router.parseUrl(this.router.url).queryParams; + if (!data) { + return; + } + NgxRxdbUtils.logger.log('restoreLocalToURL', data); + await this.upsertLocal(id, data); } + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-type-constraint */ private async init(dbService: NgxRxdbService, config: RxCollectionCreatorExtended) { const { name } = config; diff --git a/packages/rxdb/config/src/lib/rxdb.config.ts b/packages/rxdb/config/src/lib/rxdb.config.ts index 48f113c..5582607 100644 --- a/packages/rxdb/config/src/lib/rxdb.config.ts +++ b/packages/rxdb/config/src/lib/rxdb.config.ts @@ -17,7 +17,9 @@ import type { Merge, SetOptional, SetRequired } from 'type-fest'; export interface RxCollectionCreatorOptions { schemaUrl?: string; initialDocs?: any[]; + /** @deprecated */ recreate?: boolean; + persistLocalToURL?: boolean; replicationStateFactory?: (col: RxCollection) => RxReplicationState | null; } @@ -52,6 +54,19 @@ export interface RxDbMetadata { isFirstTimeInstantiated: boolean; } +/** + * RxCollection hooks names + * @see https://rxdb.info/middleware.html + */ +export type RxCollectionHooks = + | 'preInsert' + | 'preSave' + | 'preRemove' + | 'postInsert' + | 'postSave' + | 'postRemove' + | 'postCreate'; + /** * Instance of RxDatabaseCreator */ diff --git a/packages/rxdb/package.json b/packages/rxdb/package.json index 393b4d7..bfa06fe 100644 --- a/packages/rxdb/package.json +++ b/packages/rxdb/package.json @@ -3,7 +3,7 @@ "private": false, "name": "@ngx-odm/rxdb", "version": "5.0.0", - "description": "Angular 10+ wrapper (module or standalone) for RxDB - A realtime Database for the Web", + "description": "Angular 14+ wrapper (module or standalone) for RxDB - A realtime Database for the Web", "keywords": [ "angular", "ngrx", @@ -40,12 +40,12 @@ "npm": ">=8.x" }, "peerDependencies": { - "@angular/common": ">=v10", - "@angular/core": ">=v10", - "rxjs": "^6.6.7" + "@angular/common": ">=v14", + "@angular/core": ">=v14", + "rxjs": "^6.6.7 || ^7.6.0" }, "dependencies": { - "rxdb": "^15.0.0", + "rxdb": "^15.3.0", "tslib": "2.0.3" } } diff --git a/packages/rxdb/screencast.gif b/packages/rxdb/screencast.gif new file mode 100644 index 0000000..5768c79 Binary files /dev/null and b/packages/rxdb/screencast.gif differ diff --git a/packages/rxdb/signals/src/with-collection-service.ts b/packages/rxdb/signals/src/with-collection-service.ts index 9a8f3ee..696b613 100644 --- a/packages/rxdb/signals/src/with-collection-service.ts +++ b/packages/rxdb/signals/src/with-collection-service.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */ // FIXME: Remove this -import { Signal, computed, inject } from '@angular/core'; +import { Signal, computed } from '@angular/core'; import { getCallStateKeys, setError, @@ -20,9 +20,8 @@ import { NamedEntitySignals } from '@ngrx/signals/entities/src/models'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { SignalStoreFeatureResult } from '@ngrx/signals/src/signal-store-models'; import { StateSignal } from '@ngrx/signals/src/state-signal'; -import { NgxRxdbCollection } from '@ngx-odm/rxdb/collection'; +import { DEFAULT_LOCAL_DOCUMENT_ID, NgxRxdbCollection } from '@ngx-odm/rxdb/collection'; import { RxCollectionCreatorExtended } from '@ngx-odm/rxdb/config'; -import { NgxRxdbService } from '@ngx-odm/rxdb/core'; import type { MangoQuerySelector } from 'rxdb'; import { firstValueFrom, pipe, switchMap } from 'rxjs'; @@ -51,6 +50,9 @@ function getCollectionServiceKeys(options: { collection?: string }) { ? `selected${capitalize(options.collection)}Entities` : 'selectedEntities'; + const restoreFilterKey = options.collection + ? `restore${capitalize(options.collection)}Filter` + : 'restoreFilter'; const updateFilterKey = options.collection ? `update${capitalize(options.collection)}Filter` : 'updateFilter'; @@ -95,6 +97,7 @@ function getCollectionServiceKeys(options: { collection?: string }) { filterKey, selectedIdsKey, selectedEntitiesKey, + restoreFilterKey, updateFilterKey, updateSelectedKey, findKey, @@ -147,6 +150,8 @@ export type NamedCollectionServiceMethods< F extends Filter, CName extends string, > = { + [K in CName as `restore${Capitalize}Filter`]: () => void; +} & { [K in CName as `update${Capitalize}Filter`]: (filter: F) => void; } & { [K in CName as `updateSelected${Capitalize}Entities`]: ( @@ -175,6 +180,7 @@ export type NamedCollectionServiceMethods< }; export type CollectionServiceMethods = { + restoreFilter: () => void; updateFilter: (filter: F) => void; updateSelected: (id: EntityId, selected: boolean) => void; setCurrent(entity: E): void; @@ -243,6 +249,7 @@ export function withCollectionService< filterKey, selectedEntitiesKey, selectedIdsKey, + restoreFilterKey, updateFilterKey, updateSelectedKey, @@ -274,13 +281,22 @@ export function withCollectionService< }; }), withMethods((store: Record & StateSignal) => { - const collection = new NgxRxdbCollection( - inject(NgxRxdbService), - options.collectionConfig - ); + const collection = new NgxRxdbCollection(options.collectionConfig); return { - [updateFilterKey]: (filter: F): void => { + [restoreFilterKey]: async (): Promise => { + if (options.collectionConfig.options?.persistLocalToURL) { + await collection.restoreLocalFromURL(DEFAULT_LOCAL_DOCUMENT_ID); + } + const local = await collection.getLocal(DEFAULT_LOCAL_DOCUMENT_ID); + patchState(store, { [filterKey]: local[filterKey] }); + }, + [updateFilterKey]: async (filter: F): Promise => { + if (typeof filter === 'string') { + await collection.setLocal(DEFAULT_LOCAL_DOCUMENT_ID, 'filter', filter); + } else { + await collection.upsertLocal(DEFAULT_LOCAL_DOCUMENT_ID, filter); + } patchState(store, { [filterKey]: filter }); }, [updateSelectedKey]: (id: EntityId, selected: boolean): void => { @@ -297,7 +313,6 @@ export function withCollectionService< collection.docs({}).pipe( tapResponse({ next: result => { - // NgxRxdbUtils.logger.table(result); store[callStateKey] && patchState(store, setLoading(prefix)); return patchState( store, diff --git a/packages/rxdb/src/lib/rxdb.providers.ts b/packages/rxdb/src/lib/rxdb.providers.ts index b86dde8..eddc843 100644 --- a/packages/rxdb/src/lib/rxdb.providers.ts +++ b/packages/rxdb/src/lib/rxdb.providers.ts @@ -84,7 +84,7 @@ export function provideRxCollection( { provide: NgxRxdbCollectionService, useFactory: collectionServiceFactory(collectionConfig), - deps: [NgxRxdbService], + deps: [], }, ]; } diff --git a/packages/rxdb/testing/src/lib/mocks.ts b/packages/rxdb/testing/src/lib/mocks.ts index c17a3ca..e2f3335 100644 --- a/packages/rxdb/testing/src/lib/mocks.ts +++ b/packages/rxdb/testing/src/lib/mocks.ts @@ -62,6 +62,7 @@ export const TEST_DB_CONFIG_1: RxDatabaseCreator = { storage: getRxStorageMemory(), multiInstance: false, ignoreDuplicate: true, + localDocuments: true, }; export const TEST_DB_CONFIG_2: RxDatabaseCreator = { name: 'test', diff --git a/packages/rxdb/utils/src/lib/utils.spec.ts b/packages/rxdb/utils/src/lib/utils.spec.ts new file mode 100644 index 0000000..5de18a5 --- /dev/null +++ b/packages/rxdb/utils/src/lib/utils.spec.ts @@ -0,0 +1,31 @@ +import { NgxRxdbUtils } from './utils'; + +describe('NgxRxdb Utils: qsify', () => { + it('should return an empty string when given an empty object', () => { + const obj = {}; + const result = NgxRxdbUtils.qsify(obj); + expect(result).toBe(''); + }); + + it('should correctly encode and concatenate key-value pairs', () => { + const obj = { + name: 'John Doe', + age: 30, + hobbies: ['reading', 'coding'], + isActive: true, + }; + const result = NgxRxdbUtils.qsify(obj); + expect(result).toBe('name=John%20Doe&age=30&hobbies=reading,coding&isActive=true'); + }); + + it('should ignore undefined values', () => { + const obj = { + name: 'John Doe', + age: undefined, + hobbies: ['reading', undefined, 'coding'], + isActive: true, + }; + const result = NgxRxdbUtils.qsify(obj); + expect(result).toBe('name=John%20Doe&hobbies=reading,coding&isActive=true'); + }); +}); diff --git a/packages/rxdb/utils/src/lib/utils.ts b/packages/rxdb/utils/src/lib/utils.ts index 5328eff..36133b1 100644 --- a/packages/rxdb/utils/src/lib/utils.ts +++ b/packages/rxdb/utils/src/lib/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ -import { isDevMode } from '@angular/core'; +import { NgZone, isDevMode } from '@angular/core'; import type { FilledMangoQuery, PreparedQuery, RxJsonSchema } from 'rxdb'; import { prepareQuery } from 'rxdb'; import { RxReplicationState } from 'rxdb/plugins/replication'; @@ -10,7 +10,11 @@ export type AnyObject = Record; /** @internal */ export type Cast = Exclude extends never ? I : O; /** @internal */ -export type Nil = null | undefined; +type Nil = null | undefined; +/** @internal */ +type Falsy = false | '' | 0 | null | undefined; +/** @internal */ +type MaybeUndefined = undefined extends T ? undefined : never; /** @internal */ export type EmptyObject = Record; /** @internal */ @@ -53,10 +57,6 @@ export type IsKnownRecord = IsRecord extends true export namespace NgxRxdbUtils { /** * Creates a shallow clone of `value`. - * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 12,696 bytes - * - Micro-dash: 116 bytes * @param value */ export function clone(value: T): T { @@ -75,9 +75,6 @@ export namespace NgxRxdbUtils { * Differences from lodash: * - does not give any special consideration for arguments objects, strings, or prototype objects (e.g. many will have `'length'` in the returned array) * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 3,473 bytes - * - Micro-dash: 184 bytes * @internal */ export function keys(object: Nil | T): Array> { @@ -88,7 +85,6 @@ export namespace NgxRxdbUtils { return val as any; } - /** @internal */ export function keysOfNonArray(object: Nil | T): Array> { return object ? (Object.getOwnPropertyNames(object) as any) : []; } @@ -98,20 +94,14 @@ export namespace NgxRxdbUtils { * * Differences from lodash: * - does not treat sparse arrays as dense - * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 3,744 bytes - * - Micro-dash: 283 bytes * @param object * @param iteratee - * @internal */ export function forOwn(object: T, iteratee: ObjectIteratee): T { forEachOfArray(keys(object), key => iteratee(object[key as keyof T], key)); return object; } - /** @internal */ export function forOwnOfNonArray( object: T, iteratee: ObjectIteratee @@ -122,13 +112,8 @@ export namespace NgxRxdbUtils { /** * Iterates over elements of `collection` and invokes `iteratee` for each element. Iteratee functions may exit iteration early by explicitly returning `false`. - * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 4,036 bytes - * - Micro-dash: 258 bytes * @param array * @param iteratee - * @internal */ export function forEach( array: T, @@ -138,8 +123,6 @@ export namespace NgxRxdbUtils { object: T, iteratee: ObjectIteratee, boolean | void> ): T; - - /** @internal */ export function forEach(collection: any, iteratee: any): any { if (Array.isArray(collection)) { forEachOfArray(collection, iteratee); @@ -149,7 +132,6 @@ export namespace NgxRxdbUtils { return collection; } - /** @internal */ export function forEachOfArray( array: readonly T[], iteratee: ArrayIteratee @@ -171,13 +153,8 @@ export namespace NgxRxdbUtils { * - only supports arguments that are objects * - cannot handle circular references * - when merging an array onto a non-array, the result is a non-array - * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 10,882 bytes - * - Micro-dash: 438 bytes * @param object * @param source - * @internal */ export function merge(object: A, source: B): A & B; export function merge( @@ -204,12 +181,7 @@ export namespace NgxRxdbUtils { * Objects are considered empty if they have no own enumerable string keyed properties. * * Arrays are considered empty if they have a `length` of `0`. - * - * Contribution to minified bundle size, when it is the only function imported: - * - Lodash: 4,406 bytes - * - Micro-dash: 148 bytes * @param value - * @internal */ export function isEmpty(value: any): boolean { if (!Array.isArray(value)) { @@ -218,14 +190,92 @@ export namespace NgxRxdbUtils { return value.length === 0; } - /** @internal */ export const isFunction = (value: any): value is Function => typeof value === 'function'; - /** @internal */ + export const isUndefined = (value: any): value is undefined => + value === undefined || value === 'undefined'; + + export function isNullOrUndefined(value: any): value is Nil { + /* prettier-ignore */ + return value === undefined || value === null || value === 'undefined' || value === 'null'; + } + + export const isObject = (x: any): x is object => + Object.prototype.toString.call(x) === '[object Object]'; + + export function isNgZone(zone: unknown): zone is NgZone { + return zone instanceof NgZone; + } + export function noop(): void { return void 0; } + export function identity(value: T): T { + return value; + } + + export function getMaybeId(entityOrId: object | string): string { + if (isObject(entityOrId)) { + return entityOrId['_id']; + } + return String(entityOrId); + } + + export function compact(array: Array): Array> { + return array.filter(identity) as Array>; + } + + /** + * Creates an object with all empty values removed. The values [], `null`, `""`, `undefined`, and `NaN` are empty. + * @param obj Object + */ + export function compactObject(obj: T): Partial { + if (isEmpty(obj)) { + return obj; + } + if (Array.isArray(obj)) { + return compact(obj as any) as any; + } + return Object.entries(obj) + .filter( + ([, value]: [string, any]) => !isNullOrUndefined(identity(value)) && !isEmpty(value) + ) + .reduce((acc: Partial, [key, val]: [string, any]) => ({ ...acc, [key]: val }), {}); + } + + /** + * Transforms an object into an URL query string, stripping out any undefined + * values. + * @param {object} obj + * @param prefix + * @returns {string} + * @internal + */ + export function qsify(obj: any, prefix = ''): string { + const encode = (v: any): string => + encodeURIComponent(typeof v === 'boolean' ? String(v) : v); + const result: string[] = []; + forEach(obj, (value, k) => { + if (isUndefined(value)) return; + + let ks = encode(prefix + k) + '='; + if (Array.isArray(value)) { + const values: string[] = []; + forEach(value, v => { + if (isUndefined(v)) return; + + values.push(encode(v)); + }); + ks += values.join(','); + } else { + ks += encode(value); + } + result.push(ks); + }); + return result.length ? result.join('&') : ''; + } + export function isDevModeForced(): boolean { return localStorage['debug']?.includes(`@ngx-odm/rxdb`); } @@ -296,6 +346,22 @@ export namespace NgxRxdbUtils { }; } + /** + * Moves observable execution in and out of Angular zone. + * @param zone + */ + export function runInZone(zone: NgZone): OperatorFunction { + return source => { + return new Observable(subscriber => { + return source.subscribe( + (value: T) => zone.run(() => subscriber.next(value)), + (e: any) => zone.run(() => subscriber.error(e)), + () => zone.run(() => subscriber.complete()) + ); + }); + }; + } + /** * Simple rxjs exponential backoff retry operator * @param count @@ -385,3 +451,11 @@ export function isValidRxReplicationState( ): obj is RxReplicationState { return obj && obj instanceof RxReplicationState; } + +/** + * Function to sync local RxDB document property when URL query segment changes + * @param document + * @param queryParam + * @param queryParamValue + * @param property + */