diff --git a/apps/demo-api/src/app/function/guestbook/guestbookentry.function.spec.ts b/apps/demo-api/src/app/function/guestbook/guestbookentry.function.spec.ts index 55895f317..8e614a5cb 100644 --- a/apps/demo-api/src/app/function/guestbook/guestbookentry.function.spec.ts +++ b/apps/demo-api/src/app/function/guestbook/guestbookentry.function.spec.ts @@ -2,6 +2,7 @@ import { guestbookEntryUpdateEntry } from './guestbookentry.update'; import { GuestbookEntry, UpdateGuestbookEntryParams } from '@dereekb/demo-firebase'; import { demoGuestbookEntryContext, DemoApiFunctionContextFixture, demoApiFunctionContextFactory, demoAuthorizedUserContext, demoGuestbookContext } from '../../../test/fixture'; import { WrappedCloudFunction } from '@dereekb/firebase-server'; +import { isDate, isValid } from 'date-fns'; demoApiFunctionContextFactory((f: DemoApiFunctionContextFixture) => { @@ -81,11 +82,17 @@ demoApiFunctionContextFactory((f: DemoApiFunctionContextFixture) => { expect(exists).toBe(true); data = (await userGuestbookEntry.snapshotData())!; + + expect(data).toBeDefined(); expect(data?.message).toBe(newMessage); expect(data?.signed).toBe(signed); expect(data?.createdAt).not.toBeFalsy(); expect(data?.updatedAt).not.toBeFalsy(); + expect(isDate(data?.createdAt)).toBe(true); + expect(isDate(data?.updatedAt)).toBe(true); + expect(isValid(data?.createdAt)).toBe(true); + expect(isValid(data?.updatedAt)).toBe(true); }); }); diff --git a/apps/demo-firebase/src/lib/guestbook/guestbook.ts b/apps/demo-firebase/src/lib/guestbook/guestbook.ts index 12798f83e..a060b835f 100644 --- a/apps/demo-firebase/src/lib/guestbook/guestbook.ts +++ b/apps/demo-firebase/src/lib/guestbook/guestbook.ts @@ -78,7 +78,7 @@ export interface GuestbookEntry extends UserRelatedById { */ createdAt: Date; /** - * Whether or not the entry has been published. This cannot be changed once published. + * Whether or not the entry has been published. It can be unpublished at any time by the user. */ published?: boolean; } diff --git a/apps/demo/src/app/modules/demo/modules/app/modules/guestbook/container/guestbook.entry.popup.component.ts b/apps/demo/src/app/modules/demo/modules/app/modules/guestbook/container/guestbook.entry.popup.component.ts index 1a1aa8297..abc8055ff 100644 --- a/apps/demo/src/app/modules/demo/modules/app/modules/guestbook/container/guestbook.entry.popup.component.ts +++ b/apps/demo/src/app/modules/demo/modules/app/modules/guestbook/container/guestbook.entry.popup.component.ts @@ -4,6 +4,8 @@ import { MatDialog } from '@angular/material/dialog'; import { HandleActionFunction } from '@dereekb/dbx-core'; import { DemoGuestbookEntryFormValue } from '../../../../shared/modules/guestbook/component/guestbook.entry.form.component'; import { GuestbookEntryDocumentStore } from './../../../../shared/modules/guestbook/store/guestbook.entry.document.store'; +import { IsModifiedFunction } from '@dereekb/rxjs'; +import { map, of, switchMap } from 'rxjs'; export interface DemoGuestbookEntryPopupComponentConfig { guestbookEntryDocumentStore: GuestbookEntryDocumentStore; @@ -13,8 +15,8 @@ export interface DemoGuestbookEntryPopupComponentConfig { template: `

Enter your message for the guest book.

-
- +
+

@@ -27,6 +29,10 @@ export class DemoGuestbookEntryPopupComponent extends AbstractDialogDirective = (value) => { + return this.exists$.pipe( + switchMap((exists) => { + if (exists) { + return this.data$.pipe( + map((current) => { + const isModified = Boolean(current.message !== value.message) || Boolean(current.signed !== value.signed) || Boolean(current.published !== value.published); + return isModified; + })); + } else { + return of(true); + } + }) + ); + } + readonly handleUpdateEntry: HandleActionFunction = (value: DemoGuestbookEntryFormValue, context) => { context.startWorkingWithLoadingStateObservable(this.guestbookEntryDocumentStore.updateEntry(value)); } diff --git a/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.component.ts b/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.component.ts index 2d7306948..7f86bac9e 100644 --- a/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.component.ts +++ b/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.component.ts @@ -4,7 +4,7 @@ import { GuestbookEntry } from "@dereekb/demo-firebase"; import { FormlyFieldConfig } from "@ngx-formly/core"; import { guestbookEntryFields } from "./guestbook.entry.form"; -export interface DemoGuestbookEntryFormValue extends Pick { } +export interface DemoGuestbookEntryFormValue extends Pick { } @Component({ template: ``, diff --git a/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.ts b/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.ts index 63efc7417..74a854350 100644 --- a/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.ts +++ b/apps/demo/src/app/modules/demo/modules/shared/modules/guestbook/component/guestbook.entry.form.ts @@ -1,10 +1,11 @@ -import { textAreaField, textField } from "@dereekb/dbx-form"; +import { textAreaField, textField, toggleField } from "@dereekb/dbx-form"; import { GUESTBOOK_ENTRY_MESSAGE_MAX_LENGTH, GUESTBOOK_ENTRY_SIGNED_MAX_LENGTH } from "@dereekb/demo-firebase"; export function guestbookEntryFields() { return [ guestbookEntryMessageField(), - guestbookEntrySignedField() + guestbookEntrySignedField(), + guestbookEntryPublishedField() ]; } @@ -15,3 +16,7 @@ export function guestbookEntryMessageField() { export function guestbookEntrySignedField() { return textField({ key: 'signed', label: 'Signed', maxLength: GUESTBOOK_ENTRY_SIGNED_MAX_LENGTH, required: true }); } + +export function guestbookEntryPublishedField() { + return toggleField({ key: 'published', label: 'Published' }); +} diff --git a/apps/demo/src/app/modules/doc/modules/action/container/form.component.ts b/apps/demo/src/app/modules/doc/modules/action/container/form.component.ts index 7dfe7f47a..387874841 100644 --- a/apps/demo/src/app/modules/doc/modules/action/container/form.component.ts +++ b/apps/demo/src/app/modules/doc/modules/action/container/form.component.ts @@ -20,7 +20,7 @@ export class DocActionFormComponent { readonly isFormModified: IsModifiedFunction = (value: DocActionFormExampleValue) => { return this.defaultValue$.pipe( map((defaultValue) => { - const isModified = Boolean(value.name === defaultValue.name) || !isSameMinute(value.date, defaultValue.date); + const isModified = Boolean(value.name !== defaultValue.name) || !isSameMinute(value.date, defaultValue.date); return isModified; })); } diff --git a/packages/dbx-core/src/lib/action/directive/state/action.enforce.modified.directive.ts b/packages/dbx-core/src/lib/action/directive/state/action.enforce.modified.directive.ts index 4310c4eaa..9b5fc7d51 100644 --- a/packages/dbx-core/src/lib/action/directive/state/action.enforce.modified.directive.ts +++ b/packages/dbx-core/src/lib/action/directive/state/action.enforce.modified.directive.ts @@ -1,4 +1,5 @@ import { Directive, Host, Input, OnInit, OnDestroy } from '@angular/core'; +import { Maybe } from '@dereekb/util'; import { BehaviorSubject, combineLatest, delay } from 'rxjs'; import { AbstractSubscriptionDirective } from '../../../subscription'; import { DbxActionContextStoreSourceInstance } from '../../action.store.source'; @@ -32,13 +33,13 @@ export class DbxActionEnforceModifiedDirective extends AbstractSubscriptionDirec this.source.enable(APP_ACTION_ENFORCE_MODIFIED_DIRECTIVE_KEY); } - @Input('[dbxActionEnforceModified]') + @Input('dbxActionEnforceModified') get enabled(): boolean { return this._enabled.value; } - set enabled(enabled: boolean) { - this._enabled.next(enabled ?? true); + set enabled(enabled: Maybe) { + this._enabled.next(Boolean(enabled) ?? true); } } diff --git a/packages/dbx-core/src/lib/pipe/date/tojsdate.pipe.ts b/packages/dbx-core/src/lib/pipe/date/tojsdate.pipe.ts index 1e750fa9b..758fbb2b5 100644 --- a/packages/dbx-core/src/lib/pipe/date/tojsdate.pipe.ts +++ b/packages/dbx-core/src/lib/pipe/date/tojsdate.pipe.ts @@ -1,3 +1,4 @@ +import { isValid } from 'date-fns'; import { Pipe, PipeTransform } from '@angular/core'; import { toJsDate } from '@dereekb/date'; import { DateOrDateString, Maybe } from '@dereekb/util'; @@ -6,7 +7,17 @@ import { DateOrDateString, Maybe } from '@dereekb/util'; export class ToJsDatePipe implements PipeTransform { public static toJsDate(input: Maybe): Maybe { - return (input) ? toJsDate(input) : undefined; + let date: Maybe; + + if (input != null) { + date = toJsDate(input); + + if (!isValid(date)) { + date = undefined; + } + } + + return date; } transform(input: Maybe): Maybe { diff --git a/packages/dbx-firebase/src/lib/model/store/store.document.ts b/packages/dbx-firebase/src/lib/model/store/store.document.ts index f9a92eed7..9a80a2bcd 100644 --- a/packages/dbx-firebase/src/lib/model/store/store.document.ts +++ b/packages/dbx-firebase/src/lib/model/store/store.document.ts @@ -175,6 +175,11 @@ export class AbstractDbxFirebaseDocumentStore shareReplay(1) ); + readonly doesNotExist$: Observable = this.exists$.pipe( + map(x => !x), + shareReplay(1) + ); + // MARK: State Changes /** * Sets the id of the document to load. diff --git a/packages/firebase/src/lib/common/firestore/accessor/accessor.ts b/packages/firebase/src/lib/common/firestore/accessor/accessor.ts index daac05897..63f90432f 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/accessor.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/accessor.ts @@ -40,7 +40,10 @@ export interface FirestoreDocumentDataAccessor extends DocumentReferenceRef, options: SetOptions): Promise; set(data: WithFieldValue): Promise; /** - * Updates the data in the database. If the document doesn't exist, it will fail. + * Directly updates the data in the database. If the document doesn't exist, it will fail. + * + * NOTE: Update will skip any conversions and directly set the data. + * If you rely on the converter/conversion functionality, use set() with merge: true instead of update. * * @param data */ @@ -91,17 +94,18 @@ export function mapDataFromSnapshot(options?: SnapshotOptions): OperatorFunct * First checks that the data exists before writing to the datastore. * * If it does not exist, will call set without merge options in order to fully initialize the object's data. - * If it does exist, update is done on all defined values. + * If it does exist, update is done using set + merge on all defined values. * * @param data */ -export type CreateOrUpdateWithAccessorFunction = (data: Partial) => Promise; +export type CreateOrUpdateWithAccessorSetFunction = (data: Partial) => Promise; -export function createOrUpdateWithAccessor(accessor: FirestoreDocumentDataAccessor): (data: Partial) => Promise { +export function createOrUpdateWithAccessorSet(accessor: FirestoreDocumentDataAccessor): (data: Partial) => Promise { return (data: Partial) => { return accessor.exists().then((exists) => { if (exists) { - return accessor.update(filterUndefinedValues(data) as UpdateData); + const update = filterUndefinedValues(data); + return accessor.set(update, { merge: true }); } else { return accessor.set(data as WithFieldValue); } diff --git a/packages/firebase/src/lib/common/firestore/accessor/document.ts b/packages/firebase/src/lib/common/firestore/accessor/document.ts index 18f0ca7d0..2868c89a5 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/document.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/document.ts @@ -1,7 +1,7 @@ import { Observable } from 'rxjs'; import { FirestoreAccessorDriverRef } from '../driver/accessor'; import { DocumentReference, CollectionReference, Transaction, WriteBatch, DocumentSnapshot, SnapshotOptions, WriteResult } from '../types'; -import { createOrUpdateWithAccessor, dataFromSnapshotStream, FirestoreDocumentDataAccessor } from './accessor'; +import { createOrUpdateWithAccessorSet, dataFromSnapshotStream, FirestoreDocumentDataAccessor } from './accessor'; import { CollectionReferenceRef, DocumentReferenceRef } from '../reference'; import { FirestoreDocumentContext } from './context'; @@ -44,7 +44,7 @@ export abstract class AbstractFirestoreDocument): Promise { - return createOrUpdateWithAccessor(this.accessor)(data); + return createOrUpdateWithAccessorSet(this.accessor)(data); } } diff --git a/packages/firebase/src/lib/common/firestore/snapshot/snapshot.field.spec.ts b/packages/firebase/src/lib/common/firestore/snapshot/snapshot.field.spec.ts index a32330748..b788951b5 100644 --- a/packages/firebase/src/lib/common/firestore/snapshot/snapshot.field.spec.ts +++ b/packages/firebase/src/lib/common/firestore/snapshot/snapshot.field.spec.ts @@ -1,4 +1,5 @@ import { ISO8601DateString, makeModelFieldMapFunctions } from '@dereekb/util'; +import { isValid } from 'date-fns'; import { firestoreDate, firestoreField } from './snapshot.field'; describe('firestoreField()', () => { @@ -68,9 +69,10 @@ describe('firestoreDate()', () => { const converted = dateField.from!.convert!(dateString); expect(converted).toBeDefined(); expect(converted?.getTime()).toBe(value.getTime()); + expect(isValid(converted)).toBe(true); }); - it('should convert data from a date string to a .', () => { + it('should convert data from a date to a date string.', () => { const dateString = '2021-08-16T05:00:00.000Z'; const value = new Date(dateString); diff --git a/packages/firebase/src/test/common/test.driver.accessor.ts b/packages/firebase/src/test/common/test.driver.accessor.ts index f26925ef6..3041acf08 100644 --- a/packages/firebase/src/test/common/test.driver.accessor.ts +++ b/packages/firebase/src/test/common/test.driver.accessor.ts @@ -336,6 +336,8 @@ export function describeAccessorTests(init: () => DescribeAccessorTests) { } }); + // todo: test that update does not call the converter when setting values. + }); describe('set()', () => { @@ -363,6 +365,8 @@ export function describeAccessorTests(init: () => DescribeAccessorTests) { expect(c.hasDataFromUpdate(snapshot.data())).toBe(true); }); + // todo: test that set calls the converter when setting values. + }); describe('delete()', () => {