Skip to content

Commit

Permalink
Merge pull request #177 from Jujulego/feat/deleted-item-event
Browse files Browse the repository at this point in the history
Add deleted item event
  • Loading branch information
julien-capellari authored Sep 8, 2022
2 parents a43b158 + 4b24fe4 commit b4b349c
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 18 deletions.
15 changes: 13 additions & 2 deletions packages/core/src/entities/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type EntityMerge<D, R> = (stored: D, result: R) => D;
*
* Events emitted:
* - 'update.\{id\}' emitted when an item's contents changes
* - 'delete.\{id\}' emitted when an item's contents are deleted
*/
export class Entity<D> {
// Attributes
Expand All @@ -36,12 +37,22 @@ export class Entity<D> {
key: PartialKey<`update.${string}`>,
listener: EventListener<StoreEventMap<D>, `update.${string}.${string}`>,
opts?: EventListenerOptions
): EventUnsubscribe;
subscribe(
key: PartialKey<`delete.${string}`>,
listener: EventListener<StoreEventMap<D>, `delete.${string}.${string}`>,
opts?: EventListenerOptions
): EventUnsubscribe;
subscribe(
key: PartialKey<`update.${string}`> | PartialKey<`delete.${string}`>,
listener: EventListener<StoreEventMap<D>, `update.${string}.${string}`> | EventListener<StoreEventMap<D>, `delete.${string}.${string}`>,
opts?: EventListenerOptions
): EventUnsubscribe {
const [type, ...filters] = key.split('.');

return this.store.subscribe(
[type, this.name, ...filters].join('.') as PartialKey<`update.${string}.${string}`>,
listener,
[type, this.name, ...filters].join('.') as PartialKey<`update.${string}.${string}` | `delete.${string}.${string}`>,
listener as EventListener<StoreEventMap<D>, `update.${string}.${string}` | `delete.${string}.${string}`>,
opts
);
}
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/entities/item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Entity } from './entity';
*
* Events emitted:
* - 'update' emitted when item contents changes
* - 'delete' emitted when item contents are deleted
* - 'query.pending' emitted when a new query is started
* - 'query.completed' emitted when the running query completes
* - 'query.failed' emitted when the running query fails
Expand All @@ -37,20 +38,29 @@ export class Item<D> {
listener: EventListener<StoreEventMap<D>, `update.${string}.${string}`>,
opts?: EventListenerOptions
): EventUnsubscribe;
subscribe(
key: 'delete',
listener: EventListener<StoreEventMap<D>, `delete.${string}.${string}`>,
opts?: EventListenerOptions
): EventUnsubscribe;
subscribe<T extends PartialKey<EventType<QueryManagerEventMap<D>>>>(
type: T,
listener: EventListener<QueryManagerEventMap<D>, ExtractKey<EventType<QueryManagerEventMap<D>>, T>>,
opts?: EventListenerOptions
): EventUnsubscribe;
subscribe(
key: 'update' | PartialKey<EventType<QueryManagerEventMap<D>>>,
key: 'update' | 'delete' | PartialKey<EventType<QueryManagerEventMap<D>>>,
listener: EventListener<StoreEventMap<D>, `update.${string}.${string}`> | EventListener<QueryManagerEventMap<D>, ExtractKey<EventType<QueryManagerEventMap<D>>, 'query'>>,
opts?: EventListenerOptions
): EventUnsubscribe {
if (key === 'update') {
return this.entity.subscribe(`update.${this.id}`, listener as EventListener<StoreEventMap<D>, `update.${string}.${string}`>, opts);
}

if (key === 'delete') {
return this.entity.subscribe(`delete.${this.id}`, listener as EventListener<StoreEventMap<D>, `delete.${string}.${string}`>, opts);
}

return this._manager.subscribe(key, listener as EventListener<QueryManagerEventMap<D>, ExtractKey<EventType<QueryManagerEventMap<D>>, 'status'>>, opts);
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/entities/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export class List<D> extends EventSource<ListEventMap<D>> {
this._markDirty();
}
});

// Subscribe to entity delete events
this.entity.subscribe('delete', (data) => {
if (this._ids.includes(data.id)) {
this._cache = undefined;
this._markDirty();
}
});
}

// Methods
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/stores/memory.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class MemoryStore extends Store {
const old = this.get<D>(entity, id);

this._map.delete(this._key(entity, id));
this.emit(`delete.${entity}.${id}`, { id, item: old });

return old;
}
Expand Down
26 changes: 19 additions & 7 deletions packages/core/src/stores/storage.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,28 @@ export class StorageStore extends Store {

private _registerWindowEvent() {
window.addEventListener('storage', (event) => {
if (event.storageArea === this.storage && event.key && event.newValue) {
if (!event.key.startsWith('aegis:')) return;
if (event.storageArea === this.storage && event.key) {
if (!event.key.startsWith('aegis:')) {
return;
}

this._cache.delete(event.key);
const [, entity, id] = event.key.split(':');

this.emit(`update.${entity}.${id}`, {
id,
old: event.oldValue ? JSON.parse(event.oldValue) : undefined,
new: JSON.parse(event.newValue)
});
if (!event.newValue) {
// item has been deleted
this.emit(`delete.${entity}.${id}`, {
id,
item: event.oldValue ? JSON.parse(event.oldValue) : undefined,
});
} else {
// item has been updated
this.emit(`update.${entity}.${id}`, {
id,
old: event.oldValue ? JSON.parse(event.oldValue) : undefined,
new: JSON.parse(event.newValue)
});
}
}
});
}
Expand Down Expand Up @@ -76,6 +87,7 @@ export class StorageStore extends Store {

this.storage.removeItem(this._key(entity, id));
this._cache.delete(this._key(entity, id));
this.emit(`delete.${entity}.${id}`, { id, item: old });

return old;
}
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/stores/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ export interface StoreUpdateEvent<D = any> {
new: D;
}

export type StoreEventMap<D = any> = Record<`update.${string}.${string}`, StoreUpdateEvent<D>>;
export interface StoreDeleteEvent<D = any> {
id: string;
item: D;
}

export type StoreEventMap<D = any> = Record<`update.${string}.${string}`, StoreUpdateEvent<D>>
& Record<`delete.${string}.${string}`, StoreDeleteEvent<D>>;

// Store
/**
Expand All @@ -19,6 +25,7 @@ export type StoreEventMap<D = any> = Record<`update.${string}.${string}`, StoreU
*
* Events emitted:
* - 'update.\{entity\}.\{id\}' emitted when an item is updated
* - 'delete.\{entity\}.\{id\}' emitted when an item is deleted
*/
export abstract class Store extends EventSource<StoreEventMap> {
// Methods
Expand Down
26 changes: 24 additions & 2 deletions packages/core/tests/entities/entity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ beforeEach(() => {

// Tests
describe('Entity.subscribe', () => {
it('should subscribe to store with key set', () => {
it('should subscribe to store updates with key set', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `update.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

Expand All @@ -41,7 +41,7 @@ describe('Entity.subscribe', () => {
expect(store.subscribe).toHaveBeenLastCalledWith(`update.${entity.name}`, listener, undefined);
});

it('should subscribe to store with key prepended', () => {
it('should subscribe to store updates with key prepended', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `update.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

Expand All @@ -51,6 +51,28 @@ describe('Entity.subscribe', () => {

expect(store.subscribe).toHaveBeenLastCalledWith(`update.${entity.name}.item`, listener, undefined);
});

it('should subscribe to store deletes with key set', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `delete.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

jest.spyOn(store, 'subscribe').mockReturnValue(unsub);

expect(entity.subscribe('delete', listener)).toBe(unsub);

expect(store.subscribe).toHaveBeenLastCalledWith(`delete.${entity.name}`, listener, undefined);
});

it('should subscribe to store deletes with key prepended', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `delete.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

jest.spyOn(store, 'subscribe').mockReturnValue(unsub);

expect(entity.subscribe('delete.item', listener)).toBe(unsub);

expect(store.subscribe).toHaveBeenLastCalledWith(`delete.${entity.name}.item`, listener, undefined);
});
});

describe('Entity.item', () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/core/tests/entities/item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Item,
MemoryStore,
Query, EventListener, QueryState, StoreEventMap,
StoreUpdateEvent,
StoreUpdateEvent, StoreDeleteEvent,
} from '../../src';

// Types
Expand All @@ -19,6 +19,7 @@ let item: Item<TestEntity>;

const statusEventSpy = jest.fn<void, [Readonly<QueryState<TestEntity>>]>();
const updateEventSpy = jest.fn<void, [StoreUpdateEvent<TestEntity>]>();
const deleteEventSpy = jest.fn<void, [StoreDeleteEvent<TestEntity>]>();

beforeEach(() => {
store = new MemoryStore();
Expand All @@ -27,14 +28,16 @@ beforeEach(() => {

statusEventSpy.mockReset();
updateEventSpy.mockReset();
deleteEventSpy.mockReset();

item.subscribe('status', statusEventSpy);
item.subscribe('update', updateEventSpy);
item.subscribe('delete', deleteEventSpy);
});

// Tests
describe('Item.subscribe', () => {
it('should subscribe to entity with key set if type is \'updated\'', () => {
it('should subscribe to entity updates with key set', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `update.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

Expand All @@ -44,6 +47,17 @@ describe('Item.subscribe', () => {

expect(entity.subscribe).toHaveBeenCalledWith(`update.${item.id}`, listener, undefined);
});

it('should subscribe to entity deletes with key set', () => {
const listener: EventListener<StoreEventMap<TestEntity>, `delete.${string}.${string}`> = () => undefined;
const unsub = () => undefined;

jest.spyOn(entity, 'subscribe').mockReturnValue(unsub);

expect(item.subscribe('delete', listener)).toBe(unsub);

expect(entity.subscribe).toHaveBeenCalledWith(`delete.${item.id}`, listener, undefined);
});
});

describe('Item.refresh', () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/tests/entities/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ describe('new List', () => {
);
});

it('should transmit store delete event for items within result list', async () => {
list.data = [{ id: 'item-1', value: 0 }];
updateEventSpy.mockReset();

entity.deleteItem('item-1');
await new Promise((resolve) => setTimeout(resolve, 0));

expect(updateEventSpy).toHaveBeenCalledTimes(1);
expect(updateEventSpy).toHaveBeenCalledWith(
[],
{
type: 'update',
source: list,
}
);
});

it('should ignore store update event for unknown item', () => {
entity.setItem('unknown', { id: 'unknown', value: 1 });

Expand Down
17 changes: 16 additions & 1 deletion packages/core/tests/stores/memory.store.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { MemoryStore, StoreUpdateEvent } from '../../src';
import { MemoryStore, StoreDeleteEvent, StoreUpdateEvent } from '../../src';

// Setup
let store: MemoryStore;
const updateEventSpy = jest.fn<void, [StoreUpdateEvent]>();
const deleteEventSpy = jest.fn<void, [StoreDeleteEvent]>();

beforeEach(() => {
store = new MemoryStore();

updateEventSpy.mockReset();
deleteEventSpy.mockReset();
store.subscribe('update', updateEventSpy);
store.subscribe('delete', deleteEventSpy);
});

// Tests
Expand Down Expand Up @@ -74,5 +77,17 @@ describe('MemoryStore.delete', () => {
store.set('test', 'delete', 1);
expect(store.delete<number>('test', 'delete')).toBe(1);
expect(store.get<number>('test', 'delete')).toBeUndefined();

expect(deleteEventSpy).toHaveBeenCalledTimes(1);
expect(deleteEventSpy).toHaveBeenCalledWith(
{
id: 'delete',
item: 1,
},
{
type: 'delete.test.delete',
source: store,
}
);
});
});
Loading

0 comments on commit b4b349c

Please sign in to comment.