feat(sidebar): Add sidebar controls to add and remove categories

Summary:
- Refactors account-sidebar internal package:
  - Separates into smaller react components
  - Makes DisclosureTriangle its own independent component
  - Adds data to AccountSidebarStore to allow removal or addition of items for a
    specific section of the sidebar
- Adds button and input and css styles to create categories
- Adds context menu to destroy a category
- Adds new method to CategoryStore to get the icon name for the categories of
  the current account
- Removes some unused code

Test Plan: Manual

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2283
This commit is contained in:
Juan Tejada 2015-11-23 19:41:53 -08:00
parent 6aeda7583b
commit 524028e257
20 changed files with 467 additions and 119 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,18 +1,80 @@
React = require 'react'
{Actions} = require("nylas-exports")
React = require 'react'
{WorkspaceStore} = require 'nylas-exports'
SidebarSheetItem = require './account-sidebar-sheet-item'
AccountSidebarMailViewItem = require './account-sidebar-mail-view-item'
{DisclosureTriangle} = require 'nylas-component-kit'
class AccountSidebarItem extends React.Component
@displayName: "AccountSidebarItem"
render: =>
className = "item " + if @props.select then " selected" else ""
<div className={className} onClick={@_onClick} id={@props.item.id}>
<div className="name">{@props.item.displayName}</div>
</div>
@propTypes: {
item: React.PropTypes.object.isRequired
selected: React.PropTypes.object.isRequired
onToggleCollapsed: React.PropTypes.func.isRequired
collapsed: React.PropTypes.bool
onDestroyItem: React.PropTypes.func
}
_onClick: (event) =>
event.preventDefault()
Actions.selectView(@props.item.view)
@defaultProps: {
collapsed: false
}
componentDidMount: ->
if @props.onDestroyItem?
React.findDOMNode(@).addEventListener('contextmenu', @_onShowContextMenu)
componentWillUnmount: ->
if @props.onDestroyItem?
React.findDOMNode(@).removeEventListener('contextmenu', @_onShowContextMenu)
_itemComponent: (item) ->
unless item instanceof WorkspaceStore.SidebarItem
throw new Error("AccountSidebar:_itemComponents: sections contained an \
item which was not a SidebarItem")
if item.component
Component = item.component
<Component
item={item}
select={item.id is @props.selected?.id } />
else if item.mailViewFilter
<AccountSidebarMailViewItem
item={item}
mailView={item.mailViewFilter}
select={item.mailViewFilter.isEqual(@props.selected)} />
else if item.sheet
<SidebarSheetItem
item={item}
select={item.sheet.id is @props.selected?.id} />
else
throw new Error("AccountSidebarItem: each item must have a \
custom component, or a sheet or mailViewFilter")
_onShowContextMenu: =>
item = @props.item
label = item.name
remote = require 'remote'
Menu = remote.require 'menu'
MenuItem = remote.require 'menu-item'
menu = new Menu()
menu.append(new MenuItem({
label: "Delete #{label}"
click: => @props.onDestroyItem?(item)
}))
menu.popup(remote.getCurrentWindow())
render: ->
item = @props.item
<span className="item-container">
<DisclosureTriangle
collapsed={@props.collapsed}
visible={item.children.length > 0}
onToggleCollapsed={ => @props.onToggleCollapsed(item.id)} />
{@_itemComponent(item)}
</span>
module.exports = AccountSidebarItem

View file

@ -2,7 +2,6 @@ React = require 'react'
classNames = require 'classnames'
{Actions,
Utils,
ThreadCountsStore,
WorkspaceStore,
AccountStore,
FocusedMailViewStore,
@ -17,7 +16,6 @@ class AccountSidebarMailViewItem extends React.Component
@propTypes:
select: React.PropTypes.bool
item: React.PropTypes.object.isRequired
itemUnreadCount: React.PropTypes.number
mailView: React.PropTypes.object.isRequired
constructor: (@props) ->
@ -27,10 +25,13 @@ class AccountSidebarMailViewItem extends React.Component
!Utils.isEqualReact(@props, nextProps) or !Utils.isEqualReact(@state, nextState)
render: =>
isDeleted = @props.mailView?.category?.isDeleted is true
containerClass = classNames
'item': true
'selected': @props.select
'dropping': @state.isDropping
'deleted': isDeleted
<DropZone className={containerClass}
onClick={@_onClick}
@ -44,10 +45,10 @@ class AccountSidebarMailViewItem extends React.Component
</DropZone>
_renderUnreadCount: =>
return false if @props.itemUnreadCount is 0
return false unless @props.item.unreadCount
className = 'item-count-box '
className += @props.mailView.category?.name
<div className={className}>{@props.itemUnreadCount}</div>
<div className={className}>{@props.item.unreadCount}</div>
_renderIcon: ->
<RetinaImg name={@props.mailView.iconName} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />

View file

@ -0,0 +1,98 @@
React = require 'react'
_ = require 'underscore'
_str = require 'underscore.string'
{RetinaImg} = require 'nylas-component-kit'
AccountSidebarItem = require './account-sidebar-item'
class AccountSidebarSection extends React.Component
@displayName: "AccountSidebarSection"
@propTypes: {
section: React.PropTypes.object.isRequired
collapsed: React.PropTypes.object.isRequired
selected: React.PropTypes.object.isRequired
onToggleCollapsed: React.PropTypes.func.isRequired
}
constructor: (@props) ->
@state = {showCreateInput: false}
render: ->
section = @props.section
showInput = @state.showCreateInput
allowCreate = section.createItem?
<section>
<div className="heading">{section.label}</div>
{@_createItemButton(section) if allowCreate}
{@_createItemInput(section) if allowCreate and showInput}
{@_itemComponents(section.items)}
</section>
_createItemButton: ({label}) ->
<div
className="add-item-button"
onClick={@_onCreateButtonClicked.bind(@, label)}>
<RetinaImg
url="nylas://account-sidebar/assets/icon-sidebar-addcategory@2x.png"
fallback="icon-sidebar-addcategory.png"
style={height: 14, width: 14}
mode={RetinaImg.Mode.ContentPreserve} />
</div>
_createItemInput: (section) ->
label = _str.decapitalize section.label[...-1]
placeholder = "Create new #{label}"
<span className="item-container">
<div className="item add-item-container">
<div className="icon">
<RetinaImg
name="#{section.iconName}"
fallback="folder.png"
mode={RetinaImg.Mode.ContentIsMask} />
</div>
<input
type="text"
tabIndex="1"
className="input-bordered add-item-input"
autoFocus={true}
onKeyDown={_.partial @_onInputKeyDown, _, section}
placeholder={placeholder}/>
</div>
</span>
_itemComponents: (items) =>
components = []
items.forEach (item) =>
components.push(
<AccountSidebarItem
key={item.id}
item={item}
collapsed={@props.collapsed[item.id]}
selected={@props.selected}
onDestroyItem={@props.section.destroyItem}
onToggleCollapsed={@props.onToggleCollapsed} />
)
if item.children.length and not @props.collapsed[item.id]
components.push(
<section key={"#{item.id}-children"}>
{@_itemComponents(item.children)}
</section>
)
components
_onCreateButtonClicked: (sectionLabel) =>
@setState(showCreateInput: not @state.showCreateInput)
_onInputKeyDown: (event, section) =>
if event.key is 'Escape'
@setState(showCreateInput: false)
if event.key in ['Enter', 'Return']
@props.section.createItem?(event.target.value)
@setState(showCreateInput: false)
module.exports = AccountSidebarSection

View file

@ -4,6 +4,7 @@ _ = require 'underscore'
DatabaseStore,
CategoryStore,
AccountStore,
ThreadCountsStore,
WorkspaceStore,
DraftCountStore,
Actions,
@ -12,7 +13,8 @@ _ = require 'underscore'
Message,
MailViewFilter,
FocusedMailViewStore,
NylasAPI,
SyncbackCategoryTask,
DestroyCategoryTask,
Thread} = require 'nylas-exports'
class AccountSidebarStore extends NylasStore
@ -38,7 +40,9 @@ class AccountSidebarStore extends NylasStore
@listenTo CategoryStore, @_refreshSections
@listenTo WorkspaceStore, @_refreshSections
@listenTo DraftCountStore, @_refreshSections
@listenTo ThreadCountsStore, @_refreshSections
@listenTo FocusedMailViewStore, => @trigger()
@configSubscription = NylasEnv.config.observe('core.workspace.showUnreadForAllCategories', @_refreshSections)
_refreshSections: =>
account = AccountStore.current()
@ -111,6 +115,9 @@ class AccountSidebarStore extends NylasStore
@_sections.push
label: CategoryStore.categoryLabel()
items: userCategoryItemsHierarchical
iconName: CategoryStore.categoryIconName()
createItem: @_createCategory
destroyItem: @_destroyCategory
@trigger()
@ -125,5 +132,23 @@ class AccountSidebarStore extends NylasStore
id: category.id,
name: shortenedName || category.displayName
mailViewFilter: MailViewFilter.forCategory(category)
unreadCount: @_itemUnreadCount(category)
_createCategory: (displayName) ->
CategoryClass = AccountStore.current().categoryClass()
category = new CategoryClass
displayName: displayName
accountId: AccountStore.current().id
Actions.queueTask(new SyncbackCategoryTask({category}))
_destroyCategory: (sidebarItem) ->
category = sidebarItem.mailViewFilter.category
Actions.queueTask(new DestroyCategoryTask({category}))
_itemUnreadCount: (category) =>
unreadCountEnabled = NylasEnv.config.get('core.workspace.showUnreadForAllCategories')
if category and (category.name is 'inbox' or unreadCountEnabled)
return ThreadCountsStore.unreadCountForCategoryId(category.id)
return 0
module.exports = new AccountSidebarStore()

View file

@ -1,26 +1,8 @@
React = require 'react'
_ = require 'underscore'
{Actions, MailViewFilter, WorkspaceStore, ThreadCountsStore} = require("nylas-exports")
{ScrollRegion, Flexbox} = require("nylas-component-kit")
SidebarDividerItem = require("./account-sidebar-divider-item")
SidebarSheetItem = require("./account-sidebar-sheet-item")
AccountSidebarStore = require ("./account-sidebar-store")
AccountSidebarMailViewItem = require("./account-sidebar-mail-view-item")
{RetinaImg} = require 'nylas-component-kit'
class DisclosureTriangle extends React.Component
@displayName: 'DisclosureTriangle'
@propTypes:
collapsed: React.PropTypes.bool
visible: React.PropTypes.bool
onToggleCollapsed: React.PropTypes.func.isRequired
render: ->
classnames = "disclosure-triangle"
classnames += " visible" if @props.visible
classnames += " collapsed" if @props.collapsed
<div className={classnames} onClick={@props.onToggleCollapsed}><div></div></div>
{ScrollRegion} = require 'nylas-component-kit'
AccountSidebarStore = require './account-sidebar-store'
AccountSidebarSection = require './account-sidebar-section'
class AccountSidebar extends React.Component
@ -38,12 +20,9 @@ class AccountSidebar extends React.Component
componentDidMount: =>
@unsubscribers = []
@unsubscribers.push AccountSidebarStore.listen @_onStoreChange
@unsubscribers.push ThreadCountsStore.listen @_onStoreChange
@configSubscription = NylasEnv.config.observe('core.workspace.showUnreadForAllCategories', @_onStoreChange)
componentWillUnmount: =>
unsubscribe() for unsubscribe in @unsubscribers
@configSubscription?.dispose()
render: =>
<ScrollRegion style={flex:1} id="account-sidebar">
@ -54,67 +33,12 @@ class AccountSidebar extends React.Component
_sections: =>
@state.sections.map (section) =>
<section key={section.label}>
<div className="heading">{section.label}</div>
{@_itemComponents(section.items)}
</section>
_itemComponents: (items) =>
components = []
items.forEach (item) =>
components.push(
<span key={item.id} className="item-container">
<DisclosureTriangle
collapsed={@state.collapsed[item.id]}
visible={item.children.length > 0}
onToggleCollapsed={ => @_onToggleCollapsed(item.id)}/>
{@_itemComponent(item)}
</span>
)
if item.children.length and not @state.collapsed[item.id]
components.push(
<section key={"#{item.id}-children"}>
{@_itemComponents(item.children)}
</section>
)
components
_itemUnreadCount: (item) =>
category = item.mailViewFilter.category
if category and (category.name is 'inbox' or @state.unreadCountsForAll)
return @state.unreadCounts[category.id]
return 0
_itemComponent: (item) =>
unless item instanceof WorkspaceStore.SidebarItem
throw new Error("AccountSidebar:_itemComponents: sections contained an \
item which was not a SidebarItem")
if item.component
Component = item.component
<Component
item={item}
select={item.id is @state.selected?.id } />
else if item.mailViewFilter
<AccountSidebarMailViewItem
item={item}
itemUnreadCount={@_itemUnreadCount(item)}
mailView={item.mailViewFilter}
select={item.mailViewFilter.isEqual(@state.selected)} />
else if item.sheet
<SidebarSheetItem
item={item}
select={item.sheet.id is @state.selected?.id} />
else
throw new Error("AccountSidebar:_itemComponents: each item must have a \
custom component, or a sheet or mailViewFilter")
<AccountSidebarSection
key={section.label}
section={section}
collapsed={@state.collapsed}
selected={@state.selected}
onToggleCollapsed={@_onToggleCollapsed} />
_onStoreChange: =>
@setState @_getStateFromStores()
@ -127,8 +51,5 @@ class AccountSidebar extends React.Component
_getStateFromStores: =>
sections: AccountSidebarStore.sections()
selected: AccountSidebarStore.selected()
unreadCounts: ThreadCountsStore.unreadCounts()
unreadCountsForAll: NylasEnv.config.get('core.workspace.showUnreadForAllCategories')
module.exports = AccountSidebar

View file

@ -115,6 +115,7 @@ describe "AccountSidebarStore", ->
},
{
label: 'Folders',
iconName: 'folder.png',
items: [
{
id: 'a',

View file

@ -23,6 +23,27 @@
padding-left:@padding-small-horizontal;
padding-top:@padding-small-horizontal;
letter-spacing: -0.2px;
display: inline-block;
}
.add-item-button {
display: inline-block;
margin-left: @padding-small-horizontal;
cursor: pointer;
}
.add-item-container {
padding-left: @padding-small-horizontal;
align-items: center;
.add-item-input {
order: 2;
font-size: @font-size-small;
margin-left: @padding-small-horizontal * 0.85;
height: 22px;
text-indent: 4px;
width: 85%;
}
}
.item-container {
@ -112,6 +133,9 @@
color: @source-list-active-color;
img.content-mask { background-color: @source-list-active-color; }
}
&.deleted {
opacity: 0.5;
}
&:hover {
cursor: default;

View file

@ -0,0 +1,106 @@
DestroyCategoryTask = require "../../src/flux/tasks/destroy-category-task"
NylasAPI = require "../../src/flux/nylas-api"
Task = require '../../src/flux/tasks/task'
{APIError} = require '../../src/flux/errors'
{Label, Folder, DatabaseStore} = require "nylas-exports"
describe "DestroyCategoryTask", ->
pathOf = (fn) ->
fn.calls[0].args[0].path
methodOf = (fn) ->
fn.calls[0].args[0].method
accountIdOf = (fn) ->
fn.calls[0].args[0].accountId
nameOf = (fn) ->
fn.calls[0].args[0].body.display_name
makeTask = (CategoryClass) ->
category = new CategoryClass
displayName: "important emails"
accountId: "account 123"
serverId: "server-444"
new DestroyCategoryTask
category: category
describe "performLocal", ->
beforeEach ->
spyOn(DatabaseStore, 'persistModel')
it "sets an is deleted flag and persists the category", ->
task = makeTask(Folder)
task.performLocal()
expect(DatabaseStore.persistModel).toHaveBeenCalled()
model = DatabaseStore.persistModel.calls[0].args[0]
expect(model.serverId).toEqual "server-444"
expect(model.isDeleted).toBe true
describe "performRemote", ->
it "throws error when no category present", ->
waitsForPromise ->
task = makeTask(Label)
task.category = null
task.performRemote()
.then ->
throw new Error('The promise should reject')
.catch Error, (err) ->
expect(err).toBeDefined()
it "throws error when category does not have a serverId", ->
waitsForPromise ->
task = makeTask(Label)
task.category.serverId = undefined
task.performRemote()
.then ->
throw new Error('The promise should reject')
.catch Error, (err) ->
expect(err).toBeDefined()
describe "when request succeeds", ->
beforeEach ->
spyOn(NylasAPI, "makeRequest").andCallFake -> Promise.resolve("null")
it "sends API req to /labels if user uses labels", ->
task = makeTask(Label)
task.performRemote()
expect(pathOf(NylasAPI.makeRequest)).toBe "/labels/server-444"
it "sends API req to /folders if user uses folders", ->
task = makeTask(Folder)
task.performRemote()
expect(pathOf(NylasAPI.makeRequest)).toBe "/folders/server-444"
it "sends DELETE request", ->
task = makeTask(Folder)
task.performRemote()
expect(methodOf(NylasAPI.makeRequest)).toBe "DELETE"
it "sends the account id", ->
task = makeTask(Label)
task.performRemote()
expect(accountIdOf(NylasAPI.makeRequest)).toBe "account 123"
describe "when request fails", ->
beforeEach ->
spyOn(NylasEnv, 'emitError')
spyOn(DatabaseStore, 'persistModel').andCallFake ->
Promise.resolve()
spyOn(NylasAPI, 'makeRequest').andCallFake ->
Promise.reject(new APIError({statusCode: 403}))
it "updates the isDeleted flag for the category and notifies error", ->
waitsForPromise ->
task = makeTask(Folder)
spyOn(task, "_notifyUserOfError")
task.performRemote().then (status) ->
expect(status).toEqual Task.Status.Failed
expect(task._notifyUserOfError).toHaveBeenCalled()
expect(NylasEnv.emitError).toHaveBeenCalled()
expect(DatabaseStore.persistModel).toHaveBeenCalled()
model = DatabaseStore.persistModel.calls[0].args[0]
expect(model.serverId).toEqual "server-444"
expect(model.isDeleted).toBe false

View file

@ -0,0 +1,17 @@
React = require 'react'
class DisclosureTriangle extends React.Component
@displayName: 'DisclosureTriangle'
@propTypes:
collapsed: React.PropTypes.bool
visible: React.PropTypes.bool
onToggleCollapsed: React.PropTypes.func.isRequired
render: ->
classnames = "disclosure-triangle"
classnames += " visible" if @props.visible
classnames += " collapsed" if @props.collapsed
<div className={classnames} onClick={@props.onToggleCollapsed}><div></div></div>
module.exports = DisclosureTriangle

View file

@ -32,9 +32,9 @@ class Category extends Model
modelKey: 'displayName'
jsonKey: 'display_name'
'unread': Attributes.Number
queryable: true
modelKey: 'unread'
'isDeleted': Attributes.Boolean
modelKey: 'isDeleted'
jsonKey: 'is_deleted'
hue: ->
return 0 unless @displayName

View file

@ -10,7 +10,7 @@ async = require 'async'
# A 0 code is when an error returns without a status code. These are
# things like "ESOCKETTIMEDOUT"
TimeoutErrorCode = 0
PermanentErrorCodes = [400, 404, 500]
PermanentErrorCodes = [400, 403, 404, 500]
CancelledErrorCode = -123
SampleTemporaryErrorCode = 504

View file

@ -61,6 +61,18 @@ class CategoryStore extends NylasStore
else
return "Unknown"
categoryIconName: ->
account = AccountStore.current()
return "folder.png" unless account
if account.usesFolders()
return "folder.png"
else if account.usesLabels()
return "tag.png"
else
return null
# Public: Returns {Folder} or {Label}, depending on the current provider.
#
categoryClass: ->

View file

@ -533,14 +533,7 @@ class DraftStore
# don't delay the modal may come up in a state where the draft looks
# like it hasn't been restored or has been lost.
_.delay ->
remote = require('remote')
dialog = remote.require('dialog')
dialog.showMessageBox remote.getCurrentWindow(), {
type: 'warning'
buttons: ['Okay'],
message: "Error"
detail: errorMessage
}
NylasEnv.showErrorDialog(errorMessage)
, 100
module.exports = new DraftStore()

View file

@ -10,7 +10,7 @@ Location = {}
SidebarItems = {}
class WorkspaceSidebarItem
constructor: ({@id, @component, @icon, @name, @sheet, @mailViewFilter, @section, @children}) ->
constructor: ({@id, @component, @icon, @name, @sheet, @mailViewFilter, @section, @children, @unreadCount}) ->
if not @sheet and not @mailViewFilter and not @component
throw new Error("WorkspaceSidebarItem: You must provide either a sheet \
component, or a mailViewFilter for the sidebar item named #{@name}")

View file

@ -0,0 +1,79 @@
DatabaseStore = require '../stores/database-store'
Label = require '../models/label'
Folder = require '../models/folder'
Task = require './task'
ChangeFolderTask = require './change-folder-task'
ChangeLabelTask = require './change-labels-task'
SyncbackCategoryTask = require './syncback-category-task'
NylasAPI = require '../nylas-api'
{APIError} = require '../errors'
class DestroyCategoryTask extends Task
constructor: ({@category}={}) ->
super
label: ->
name = @category.displayName
if @category instanceof Label
"Deleting label #{name}..."
else
"Deleting folder #{name}..."
isDependentTask: (other) ->
(other instanceof ChangeFolderTask) or
(other instanceof ChangeLabelTask) or
(other instanceof SyncbackCategoryTask)
performLocal: ->
if not @category
return Promise.reject(new Error("Attempt to call DestroyCategoryTask.performLocal without @category."))
@category.isDeleted = true
DatabaseStore.persistModel @category
performRemote: ->
if not @category
return Promise.reject(new Error("Attempt to call DestroyCategoryTask.performRemote without @category."))
if not @category.serverId
return Promise.reject(new Error("Attempt to call DestroyCategoryTask.performRemote without @category.serverId."))
if @category instanceof Label
path = "/labels/#{@category.serverId}"
else
path = "/folders/#{@category.serverId}"
NylasAPI.makeRequest
path: path
method: 'DELETE'
accountId: @category.accountId
returnsModel: false
.then ->
return Promise.resolve(Task.Status.Success)
.catch APIError, (err) =>
if err.statusCode in NylasAPI.PermanentErrorCodes
# Revert isDeleted flag
@category.isDeleted = false
DatabaseStore.persistModel(@category).then =>
NylasEnv.emitError(
new Error("Deleting category responded with #{err.statusCode}!")
)
@_notifyUserOfError()
return Promise.resolve(Task.Status.Failed)
else
return Promise.resolve(Task.Status.Retry)
_notifyUserOfError: (category = @category) ->
displayName = category.displayName
label = if category instanceof Label
'label'
else
'folder'
msg = "The #{label} #{displayName} could not be deleted."
if label is 'folder'
msg += " Make sure the folder you want to delete is empty before deleting it."
NylasEnv.showErrorDialog(msg)
module.exports = DestroyCategoryTask

View file

@ -30,6 +30,7 @@ class NylasComponentKit
@load "InjectedComponentSet", 'injected-component-set'
@load "TimeoutTransitionGroup", 'timeout-transition-group'
@load "ConfigPropContainer", "config-prop-container"
@load "DisclosureTriangle", "disclosure-triangle"
@load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-region'

View file

@ -86,6 +86,7 @@ class NylasExports
@require "ChangeLabelsTask", 'flux/tasks/change-labels-task'
@require "ChangeFolderTask", 'flux/tasks/change-folder-task'
@require "SyncbackCategoryTask", 'flux/tasks/syncback-category-task'
@require "DestroyCategoryTask", 'flux/tasks/destroy-category-task'
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'

View file

@ -140,10 +140,8 @@ class CategoryMailViewFilter extends MailViewFilter
if cat.name
@iconName = "#{cat.name}.png"
else if AccountStore.current().usesLabels()
@iconName = "tag.png"
else
@iconName = "folder.png"
@iconName = CategoryStore.categoryIconName()
@

View file

@ -843,6 +843,15 @@ class NylasEnvConstructor extends Model
dialog = remote.require('dialog')
dialog.showSaveDialog(@getCurrentWindow(), {title: 'Save File', defaultPath}, callback)
showErrorDialog: (message) ->
dialog = remote.require('dialog')
dialog.showMessageBox null, {
type: 'warning'
buttons: ['Okay'],
message: "Error"
detail: message
}
saveSync: ->
stateString = JSON.stringify(@savedState)
if statePath = @constructor.getStatePath()