mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-11 18:32:20 +08:00
e09d3e3e75
Summary: This diff centralizes logic for creating common tasks for things like moving to trash, archive, etc. TaskFactory exposes a set of convenience methods and hides the whole "and also remove the current label" business from the user. This diff also formally separates the concept of "moving to trash" and "archiving" so that "remove" isn't used in an unclear way. I also refactored where selection is managed. Previously you'd fire some action like archiveSelection and it'd clear the selection, but if you selected some items and used another method to archive a few, they were still selected. The selection is now bound to the ModelView as intended, so if items are removed from the modelView, they are removed from it's attached selection. This means that it shouldn't /technically/ be possible to have selected items which are not in view. I haven't refactored the tests yet. They are likely broken... Fix next/prev logic Test Plan: Run tests Reviewers: evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2157
382 lines
16 KiB
CoffeeScript
382 lines
16 KiB
CoffeeScript
_ = require 'underscore'
|
|
EventEmitter = require('events').EventEmitter
|
|
proxyquire = require 'proxyquire'
|
|
Label = require '../src/flux/models/label'
|
|
Thread = require '../src/flux/models/thread'
|
|
Message = require '../src/flux/models/message'
|
|
|
|
DatabaseStore = require '../src/flux/stores/database-store'
|
|
DatabaseView = proxyquire '../src/flux/stores/database-view',
|
|
DatabaseStore: DatabaseStore
|
|
|
|
describe "DatabaseView", ->
|
|
beforeEach ->
|
|
@queries = []
|
|
spyOn(DatabaseStore, 'run').andCallFake (query) =>
|
|
new Promise (resolve, reject) =>
|
|
query.resolve = resolve
|
|
@queries.push(query)
|
|
|
|
describe "constructor", ->
|
|
it "should require a model class", ->
|
|
expect(( -> new DatabaseView())).toThrow()
|
|
expect(( -> new DatabaseView(Thread))).not.toThrow()
|
|
view = new DatabaseView(Thread)
|
|
expect(view.klass).toBe(Thread)
|
|
|
|
it "should optionally populate matchers and includes", ->
|
|
config =
|
|
matchers: [Message.attributes.accountId.equal('asd')]
|
|
includes: [Message.attributes.body]
|
|
view = new DatabaseView(Message, config)
|
|
expect(view._matchers).toEqual(config.matchers)
|
|
expect(view._includes).toEqual(config.includes)
|
|
|
|
it "should optionally populate ordering", ->
|
|
config =
|
|
orders: [Message.attributes.date.descending()]
|
|
view = new DatabaseView(Message, config)
|
|
expect(view._orders).toEqual(config.orders)
|
|
|
|
it "should optionally accept a metadata provider", ->
|
|
provider = ->
|
|
view = new DatabaseView(Message, {}, provider)
|
|
expect(view._metadataProvider).toEqual(provider)
|
|
|
|
it "should initialize the row count to -1", ->
|
|
view = new DatabaseView(Message)
|
|
expect(view.count()).toBe(-1)
|
|
|
|
it "should immediately start fetching a row count", ->
|
|
config =
|
|
matchers: [Message.attributes.accountId.equal('asd')]
|
|
view = new DatabaseView(Message, config)
|
|
|
|
# Count query
|
|
expect(@queries[0]._count).toEqual(true)
|
|
expect(@queries[0]._matchers).toEqual(config.matchers)
|
|
|
|
describe "instance methods", ->
|
|
beforeEach ->
|
|
config =
|
|
matchers: [Message.attributes.accountId.equal('asd')]
|
|
@view = new DatabaseView(Message, config)
|
|
@view._pages =
|
|
0:
|
|
items: [new Thread(id: 'a'), new Thread(id: 'b'), new Thread(id: 'c')]
|
|
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
|
|
loaded: true
|
|
1:
|
|
items: [new Thread(id: 'd'), new Thread(id: 'e'), new Thread(id: 'f')]
|
|
metadata: {'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'}
|
|
loaded: true
|
|
@view._count = 1
|
|
spyOn(@view, 'invalidateRetainedRange').andCallFake ->
|
|
|
|
describe "setMetadataProvider", ->
|
|
it "should empty the page cache and re-fetch all pages", ->
|
|
@view.setMetadataProvider( -> false)
|
|
expect(@view._pages).toEqual({})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
describe "setMatchers", ->
|
|
it "should reset the row count", ->
|
|
@view.setMatchers([])
|
|
expect(@view._count).toEqual(-1)
|
|
|
|
it "should empty the page cache and re-fetch all pages", ->
|
|
@view.setMatchers([])
|
|
expect(@view._pages).toEqual({})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
describe "setIncludes", ->
|
|
it "should empty the page cache and re-fetch all pages", ->
|
|
@view.setIncludes([])
|
|
expect(@view._pages).toEqual({})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
|
|
describe "invalidate", ->
|
|
it "should clear the metadata cache for each page and re-fetch", ->
|
|
@view.invalidate({shallow: false})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
expect(@view._pages[0].metadata).toEqual({})
|
|
|
|
describe "when the shallow option is provided", ->
|
|
it "should refetch items in each page, but not flush the item metadata cache", ->
|
|
beforeMetadata = @view._pages[0].metadata
|
|
@view.invalidate({shallow: true})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
expect(@view._pages[0].metadata).toEqual(beforeMetadata)
|
|
|
|
describe "when the shallow option is provided with specific changed items", ->
|
|
it "should determine whether changes to these items make page(s) invalid", ->
|
|
spyOn(@view, 'invalidateAfterDatabaseChange').andCallFake ->
|
|
@view.invalidate({shallow: true, change: {objects: ['a'], type: 'persist'}})
|
|
expect(@view.invalidateAfterDatabaseChange).toHaveBeenCalled()
|
|
|
|
describe "invalidateMetadataFor", ->
|
|
it "should clear cached metadata for just the items whose ids are provided", ->
|
|
expect(@view._pages[0].metadata).toEqual({'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'})
|
|
expect(@view._pages[1].metadata).toEqual({'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'})
|
|
@view.invalidateMetadataFor(['b', 'e'])
|
|
expect(@view._pages[0].metadata['b']).toBe(undefined)
|
|
expect(@view._pages[1].metadata['e']).toBe(undefined)
|
|
|
|
it "should re-retrieve page metadata for only impacted pages", ->
|
|
spyOn(@view, 'retrievePageMetadata')
|
|
@view.invalidateMetadataFor(['e'])
|
|
expect(@view.retrievePageMetadata).toHaveBeenCalled()
|
|
expect(@view.retrievePageMetadata.calls[0].args[0]).toEqual('1')
|
|
|
|
describe "invalidateAfterDatabaseChange", ->
|
|
beforeEach ->
|
|
@inbox = new Label(id: 'l-1', name: 'inbox', displayName: 'Inbox')
|
|
@archive = new Label(id: 'l-2', name: 'archive', displayName: 'archive')
|
|
@a = new Thread(id: 'a', subject: 'a', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
@b = new Thread(id: 'b', subject: 'b', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
@c = new Thread(id: 'c', subject: 'c', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
@d = new Thread(id: 'd', subject: 'd', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
@e = new Thread(id: 'e', subject: 'e', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
@f = new Thread(id: 'f', subject: 'f', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
|
|
|
|
@view = new DatabaseView Thread,
|
|
matchers: [Thread.attributes.labels.contains('l-1')]
|
|
@view._pages =
|
|
"0":
|
|
items: [@a, @b, @c]
|
|
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
|
|
loaded: true
|
|
"1":
|
|
items: [@d, @e, @f]
|
|
metadata: {'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'}
|
|
loaded: true
|
|
spyOn(@view, 'invalidateRetainedRange')
|
|
|
|
it "should invalidate the entire range if more than 5 items are provided", ->
|
|
@view.invalidateAfterDatabaseChange({objects:[@a, @b, @c, @d, @e, @f], type:'persist'})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
it "should invalidate the entire range if a provided item is in the set but no longer matches the set", ->
|
|
a = new Thread(@a)
|
|
a.labels = [@archive]
|
|
@view.invalidateAfterDatabaseChange({objects:[a], type:'persist'})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
it "should invalidate the entire range if a provided item is not in the set but matches the set", ->
|
|
incoming = new Thread(id: 'a', subject: 'a', labels:[@inbox], lastMessageReceivedTimestamp: new Date())
|
|
@view.invalidateAfterDatabaseChange({objects:[incoming], type:'persist'})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
it "should invalidate the entire range if a provided item matches the set and the value of it's sorting attribute has changed", ->
|
|
a = new Thread(@a)
|
|
a.lastMessageReceivedTimestamp = new Date(1428526909533)
|
|
@view.invalidateAfterDatabaseChange({objects:[a], type:'persist'})
|
|
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
|
|
|
|
it "should not do anything if no provided items are in the set or belong in the set", ->
|
|
archived = new Thread(id: 'zz', labels: [@archive])
|
|
@view.invalidateAfterDatabaseChange({objects:[archived], type: 'persist'})
|
|
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
|
|
|
|
it "should replace items in place otherwise", ->
|
|
a = new Thread(@a)
|
|
a.subject = 'Subject changed, nothing to see here!'
|
|
@view.invalidateAfterDatabaseChange({objects:[a], type: 'persist'})
|
|
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
|
|
|
|
a = new Thread(@a)
|
|
a.labels = [@inbox, @archive] # not realistic, but doesn't change membership in set
|
|
@view.invalidateAfterDatabaseChange({objects:[a], type: 'persist'})
|
|
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
|
|
|
|
it "should attach the metadata field to replaced items", ->
|
|
spyOn(@view._emitter, 'emit')
|
|
subject = 'Subject changed, nothing to see here!'
|
|
runs ->
|
|
e = new Thread(@e)
|
|
e.subject = subject
|
|
@view.invalidateAfterDatabaseChange({objects:[e], type: 'persist'})
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
runs ->
|
|
expect(@view._pages[1].items[1].id).toEqual(@e.id)
|
|
expect(@view._pages[1].items[1].subject).toEqual(subject)
|
|
expect(@view._pages[1].items[1].metadata).toEqual(@view._pages[1].metadata[@e.id])
|
|
|
|
describe "when items have been removed", ->
|
|
beforeEach ->
|
|
spyOn(@view._emitter, 'emit')
|
|
@start = @view._pages[1].lastTouchTime
|
|
runs ->
|
|
b = new Thread(@b)
|
|
b.labels = []
|
|
@view.invalidateAfterDatabaseChange({objects:[b], type: 'persist'})
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
|
|
it "should optimistically remove them and shift result pages", ->
|
|
expect(@view._pages[0].items).toEqual([@a, @c, @d])
|
|
expect(@view._pages[1].items).toEqual([@e, @f])
|
|
|
|
it "should change the lastTouchTime date of changed pages so that refreshes started before the replacement do not revert it's changes", ->
|
|
expect(@view._pages[0].lastTouchTime isnt @start).toEqual(true)
|
|
expect(@view._pages[1].lastTouchTime isnt @start).toEqual(true)
|
|
|
|
describe "when items have been unpersisted but still match criteria", ->
|
|
beforeEach ->
|
|
spyOn(@view._emitter, 'emit')
|
|
@start = @view._pages[1].lastTouchTime
|
|
runs ->
|
|
@view.invalidateAfterDatabaseChange({objects:[@b], type: 'unpersist'})
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
|
|
it "should optimistically remove them and shift result pages", ->
|
|
expect(@view._pages[0].items).toEqual([@a, @c, @d])
|
|
expect(@view._pages[1].items).toEqual([@e, @f])
|
|
|
|
it "should change the lastTouchTime date of changed pages so that refreshes started before the replacement do not revert it's changes", ->
|
|
expect(@view._pages[0].lastTouchTime isnt @start).toEqual(true)
|
|
expect(@view._pages[1].lastTouchTime isnt @start).toEqual(true)
|
|
|
|
describe "cullPages", ->
|
|
beforeEach ->
|
|
@view._retainedRange = {start: 200, end: 399}
|
|
@view._pages = {}
|
|
for i in [0..9]
|
|
@view._pages[i] =
|
|
items: [new Thread(id: 'a'), new Thread(id: 'b'), new Thread(id: 'c')]
|
|
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
|
|
loaded: true
|
|
|
|
it "should not remove pages in the retained range", ->
|
|
@view.cullPages()
|
|
expect(@view._pages[2]).toBeDefined()
|
|
expect(@view._pages[3]).toBeDefined()
|
|
expect(@view._pages[4]).toBeDefined()
|
|
|
|
it "should remove pages far from the retained range", ->
|
|
@view.cullPages()
|
|
expect(@view._pages[7]).not.toBeDefined()
|
|
expect(@view._pages[8]).not.toBeDefined()
|
|
expect(@view._pages[9]).not.toBeDefined()
|
|
|
|
describe "retrievePage", ->
|
|
beforeEach ->
|
|
@config =
|
|
matchers: [Message.attributes.accountId.equal('asd')]
|
|
orders: [Message.attributes.date.descending()]
|
|
@view = new DatabaseView(Message, @config)
|
|
@queries = []
|
|
|
|
it "should initialize the page and set loading to true", ->
|
|
@view.retrievePage(0)
|
|
expect(@view._pages[0].metadata).toEqual({})
|
|
expect(@view._pages[0].items).toEqual([])
|
|
expect(@view._pages[0].loading).toEqual(true)
|
|
|
|
it "should make a database query for the correct item range", ->
|
|
@view.retrievePage(2)
|
|
expect(@queries.length).toBe(1)
|
|
expect(@queries[0]._range).toEqual({offset: @view._pageSize * 2, limit: @view._pageSize})
|
|
expect(@queries[0]._matchers).toEqual(@config.matchers)
|
|
|
|
it "should order results properly", ->
|
|
@view.retrievePage(2)
|
|
expect(@queries.length).toBe(1)
|
|
expect(@queries[0]._orders).toEqual(@config.orders)
|
|
|
|
describe "once the database request has completed", ->
|
|
beforeEach ->
|
|
@view.retrievePage(0)
|
|
@completeQuery = =>
|
|
@items = [new Thread(id: 'model-a'), new Thread(id: 'model-b'), new Thread(id: 'model-c')]
|
|
@queries[0].resolve(@items)
|
|
@queries = []
|
|
spyOn(@view, 'loaded').andCallFake -> true
|
|
spyOn(@view._emitter, 'emit')
|
|
|
|
it "should populate the page items and call trigger", ->
|
|
runs ->
|
|
@completeQuery()
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
runs ->
|
|
expect(@view._pages[0].items).toEqual(@items)
|
|
expect(@view._emitter.emit).toHaveBeenCalled()
|
|
|
|
it "should set loading to false for the page", ->
|
|
runs ->
|
|
expect(@view._pages[0].loading).toEqual(true)
|
|
@completeQuery()
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
runs ->
|
|
expect(@view._pages[0].loading).toEqual(false)
|
|
|
|
describe "if an item metadata provider is configured", ->
|
|
beforeEach ->
|
|
@view._metadataProvider = (ids) ->
|
|
results = {}
|
|
for id in ids
|
|
results[id] = "metadata-for-#{id}"
|
|
Promise.resolve(results)
|
|
|
|
it "should set .metadata of each item", ->
|
|
runs ->
|
|
@completeQuery()
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
runs ->
|
|
expect(@view._pages[0].items[0].metadata).toEqual('metadata-for-model-a')
|
|
expect(@view._pages[0].items[1].metadata).toEqual('metadata-for-model-b')
|
|
|
|
it "should cache the metadata on the page object", ->
|
|
runs ->
|
|
@completeQuery()
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
runs ->
|
|
expect(@view._pages[0].metadata).toEqual
|
|
'model-a': 'metadata-for-model-a'
|
|
'model-b': 'metadata-for-model-b'
|
|
'model-c': 'metadata-for-model-c'
|
|
|
|
it "should always wait for metadata promises to resolve", ->
|
|
@resolves = []
|
|
@view._metadataProvider = (ids) =>
|
|
new Promise (resolve, reject) =>
|
|
results = {}
|
|
for id in ids
|
|
results[id] = "metadata-for-#{id}"
|
|
@resolves.push -> resolve(results)
|
|
|
|
runs ->
|
|
@completeQuery()
|
|
expect(@view._pages[0].items).toEqual([])
|
|
expect(@view._pages[0].metadata).toEqual({})
|
|
expect(@view._emitter.emit).not.toHaveBeenCalled()
|
|
|
|
waitsFor ->
|
|
@resolves.length > 0
|
|
|
|
runs ->
|
|
for resolve,idx in @resolves
|
|
resolve()
|
|
|
|
waitsFor ->
|
|
advanceClock(1)
|
|
@view._emitter.emit.callCount > 0
|
|
|
|
runs ->
|
|
expect(@view._pages[0].items[0].metadata).toEqual('metadata-for-model-a')
|
|
expect(@view._pages[0].items[1].metadata).toEqual('metadata-for-model-b')
|
|
expect(@view._emitter.emit).toHaveBeenCalled()
|