feat(templates): Final examples package is in - templates!

This commit is contained in:
Ben Gotow 2015-10-03 14:05:47 -07:00
parent 28bf9117bd
commit 94bdcc6900
16 changed files with 595 additions and 38 deletions

View file

@ -0,0 +1,20 @@
<p>
Hi there <code class="var empty">First Name</code>,
</p>
<p>
Welcome to the templates package! Templates live in the <strong>~/.nylas/templates</strong>
directory on your computer. Each template is an HTML file - the name of the
file is the name of the template, and it's contents are the default message body.
</p>
<p>
If you include HTML &lt;code&gt; tags in your template, you can create
regions that you can jump between and fill easily. Check out the source of
the template for a <code class="var">super awesome</code> example!
</p>
<p>
Give &lt;code&gt; tags the `var` class to mark them as template regions. Add
the `empty` class to make them dark yellow. When you send your message, &lt;code&gt;
tags are always stripped so the recipient never sees any highlighting.
<p>
- Nylas Team
</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,23 @@
{ComponentRegistry, DraftStore, React} = require 'nylas-exports'
TemplatePicker = require './template-picker'
TemplateStatusBar = require './template-status-bar'
Extension = require './template-draft-extension'
module.exports =
item: null # The DOM item the main React component renders into
activate: (@state={}) ->
ComponentRegistry.register TemplatePicker,
role: 'Composer:ActionButton'
ComponentRegistry.register TemplateStatusBar,
role: 'Composer:Footer'
DraftStore.registerExtension(Extension)
deactivate: ->
ComponentRegistry.unregister(TemplatePicker)
ComponentRegistry.unregister(TemplateStatusBar)
DraftStore.unregisterExtension(Extension)
serialize: -> @state

View file

@ -0,0 +1,92 @@
{DraftStoreExtension} = require 'nylas-exports'
class TemplatesDraftStoreExtension extends DraftStoreExtension
@warningsForSending: (draft) ->
warnings = []
if draft.body.search(/<code[^>]*empty[^>]*>/i) > 0
warnings.push("with an empty template area")
warnings
@finalizeSessionBeforeSending: (session) ->
body = session.draft().body
clean = body.replace(/<\/?code[^>]*>/g, '')
if body != clean
session.changes.add(body: clean)
@onMouseUp: (editableNode, range, event) ->
parent = range.startContainer?.parentNode
parentCodeNode = null
while parent and parent isnt editableNode
if parent.classList?.contains('var') and parent.tagName is 'CODE'
parentCodeNode = parent
break
parent = parent.parentNode
isSinglePoint = range.startContainer is range.endContainer and range.startOffset is range.endOffset
if isSinglePoint and parentCodeNode
range.selectNode(parentCodeNode)
selection = document.getSelection()
selection.removeAllRanges()
selection.addRange(range)
@onTabDown: (editableNode, range, event) ->
if event.shiftKey
@onTabSelectNextVar(editableNode, range, event, -1)
else
@onTabSelectNextVar(editableNode, range, event, 1)
@onTabSelectNextVar: (editableNode, range, event, delta) ->
return unless range
# Try to find the node that the selection range is
# currently intersecting with (inside, or around)
parentCodeNode = null
nodes = editableNode.querySelectorAll('code.var')
for node in nodes
if range.intersectsNode(node)
parentCodeNode = node
if parentCodeNode
if range.startOffset is range.endOffset and parentCodeNode.classList.contains('empty')
# If the current node is empty and it's a single insertion point,
# select the current node rather than advancing to the next node
selectNode = parentCodeNode
else
# advance to the next code node
matches = editableNode.querySelectorAll('code.var')
matchIndex = -1
for match, idx in matches
if match is parentCodeNode
matchIndex = idx
break
if matchIndex != -1 and matchIndex + delta >= 0 and matchIndex + delta < matches.length
selectNode = matches[matchIndex+delta]
if selectNode
range.selectNode(selectNode)
selection = document.getSelection()
selection.removeAllRanges()
selection.addRange(range)
event.preventDefault()
event.stopPropagation()
@onInput: (editableNode, event) ->
selection = document.getSelection()
isWithinNode = (node) ->
test = selection.baseNode
while test isnt editableNode
return true if test is node
test = test.parentNode
return false
codeTags = editableNode.querySelectorAll('code.var.empty')
for codeTag in codeTags
if selection.containsNode(codeTag) or isWithinNode(codeTag)
codeTag.classList.remove('empty')
module.exports = TemplatesDraftStoreExtension

View file

@ -0,0 +1,86 @@
{Actions, Message, DatabaseStore, React} = require 'nylas-exports'
{Popover, Menu, RetinaImg} = require 'nylas-component-kit'
TemplateStore = require './template-store'
class TemplatePicker extends React.Component
@displayName: 'TemplatePicker'
@containerStyles:
order:2
constructor: (@props) ->
@state =
searchValue: ""
templates: TemplateStore.items()
componentDidMount: =>
@unsubscribe = TemplateStore.listen @_onStoreChange
componentWillUnmount: =>
@unsubscribe() if @unsubscribe
render: =>
button = <button className="btn btn-toolbar narrow">
<RetinaImg url="nylas://N1-Composer-Templates/assets/icon-composer-templates@2x.png" mode={RetinaImg.Mode.ContentIsMask}/>
&nbsp;
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
headerComponents = [
<input type="text"
tabIndex="1"
key="textfield"
className="search"
value={@state.searchValue}
onChange={@_onSearchValueChange}/>
]
footerComponents = [
<div className="item" key="new" onMouseDown={@_onNewTemplate}>Save Draft as Template...</div>
<div className="item" key="manage" onMouseDown={@_onManageTemplates}>Open Templates Folder...</div>
]
<Popover ref="popover" className="template-picker pull-right" buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={footerComponents}
items={@state.templates}
itemKey={ (item) -> item.id }
itemContent={ (item) -> item.name }
onSelect={@_onChooseTemplate}
/>
</Popover>
_filteredTemplates: (search) =>
search ?= @state.searchValue
items = TemplateStore.items()
return items unless search.length
items.filter (t) ->
t.name.toLowerCase().indexOf(search.toLowerCase()) == 0
_onStoreChange: =>
@setState
templates: @_filteredTemplates()
_onSearchValueChange: =>
newSearch = event.target.value
@setState
searchValue: newSearch
templates: @_filteredTemplates(newSearch)
_onChooseTemplate: (template) =>
Actions.insertTemplateId({templateId:template.id, draftClientId: @props.draftClientId})
@refs.popover.close()
_onManageTemplates: =>
Actions.showTemplates()
_onNewTemplate: =>
Actions.createTemplate({draftClientId: @props.draftClientId})
module.exports = TemplatePicker

View file

@ -0,0 +1,45 @@
{Actions, Message, DraftStore, React} = require 'nylas-exports'
class TemplateStatusBar extends React.Component
@displayName: 'TemplateStatusBar'
@containerStyles:
textAlign:'center'
width:530
margin:'auto'
@propTypes:
draftClientId: React.PropTypes.string
constructor: (@props) ->
@state = draft: null
componentDidMount: =>
DraftStore.sessionForClientId(@props.draftClientId).then (_proxy) =>
return if @_unmounted
return unless _proxy.draftClientId is @props.draftClientId
@_proxy = _proxy
@unsubscribe = @_proxy.listen(@_onDraftChange, @)
@_onDraftChange()
componentWillUnmount: =>
@_unmounted = true
@unsubscribe() if @unsubscribe
render: =>
if @_draftUsesTemplate()
<div className="template-status-bar">
Press "tab" to quickly fill in the blanks - highlighting will not be visible to recipients.
</div>
else
<div></div>
_onDraftChange: =>
@setState(draft: @_proxy.draft())
_draftUsesTemplate: =>
return unless @state.draft
@state.draft.body.search(/<code[^>]*class="var[^>]*>/i) > 0
module.exports = TemplateStatusBar

View file

@ -0,0 +1,109 @@
{DatabaseStore, DraftStore, Actions, Message, React} = require 'nylas-exports'
NylasStore = require 'nylas-store'
shell = require 'shell'
path = require 'path'
fs = require 'fs'
class TemplateStore extends NylasStore
constructor: ->
@_setStoreDefaults()
@_registerListeners()
@_templatesDir = path.join(atom.getConfigDirPath(), 'templates')
@_welcomeName = 'Welcome to Templates.html'
@_welcomePath = path.join(__dirname, '..', 'assets', @_welcomeName)
# I know this is a bit of pain but don't do anything that
# could possibly slow down app launch
fs.exists @_templatesDir, (exists) =>
if exists
@_populate()
fs.watch @_templatesDir, => @_populate()
else
fs.mkdir @_templatesDir, =>
fs.readFile @_welcomePath, (err, welcome) =>
fs.writeFile path.join(@_templatesDir, @_welcomeName), welcome, (err) =>
fs.watch @_templatesDir, => @_populate()
########### PUBLIC #####################################################
items: =>
@_items
templatesDirectory: =>
@_templatesDir
########### PRIVATE ####################################################
_setStoreDefaults: =>
@_items = []
_registerListeners: =>
@listenTo Actions.insertTemplateId, @_onInsertTemplateId
@listenTo Actions.createTemplate, @_onCreateTemplate
@listenTo Actions.showTemplates, @_onShowTemplates
_populate: =>
fs.readdir @_templatesDir, (err, filenames) =>
@_items = []
for filename in filenames
continue if filename[0] is '.'
displayname = path.basename(filename, path.extname(filename))
@_items.push
id: filename,
name: displayname,
path: path.join(@_templatesDir, filename)
@trigger(@)
_onCreateTemplate: ({draftClientId, name, contents} = {}) =>
if draftClientId
DraftStore.sessionForClientId(draftClientId).then (session) =>
draft = session.draft()
name ?= draft.subject
contents ?= draft.body
if not name or name.length is 0
return @_displayError("Give your draft a subject to name your template.")
if not contents or contents.length is 0
return @_displayError("To create a template you need to fill the body of the current draft.")
@_writeTemplate(name, contents)
else
if not name or name.length is 0
return @_displayError("You must provide a name for your template.")
if not contents or contents.length is 0
return @_displayError("You must provide contents for your template.")
@_writeTemplate(name, contents)
_onShowTemplates: =>
shell.showItemInFolder(@_items[0]?.path || @_templatesDir)
_displayError: (message) =>
dialog = require('remote').require('dialog')
dialog.showErrorBox('Template Creation Error', message)
_writeTemplate: (name, contents) =>
filename = "#{name}.html"
templatePath = path.join(@_templatesDir, filename)
fs.writeFile templatePath, contents, (err) =>
@_displayError(err) if err
shell.showItemInFolder(templatePath)
@_items.push
id: filename,
name: name,
path: templatePath
@trigger(@)
_onInsertTemplateId: ({templateId, draftClientId} = {}) =>
template = null
for item in @_items
template = item if item.id is templateId
return unless template
fs.readFile template.path, (err, data) ->
body = data.toString()
DraftStore.sessionForClientId(draftClientId).then (session) ->
session.changes.add(body: body)
module.exports = new TemplateStore()

View file

@ -0,0 +1,22 @@
{
"name": "N1-Composer-Templates",
"version": "0.1.0",
"main": "./lib/main",
"isStarterPackage": true,
"title": "Templates",
"description": "Create templates you can use to pre-fill the composer - never type the same email again!",
"icon": "./icon.png",
"license": "Proprietary",
"private": true,
"engines": {
"atom": "*"
},
"dependencies": {
},
"windowTypes": {
"default": true,
"composer": true
}
}

View file

@ -0,0 +1,139 @@
{Message, Actions, DatabaseStore, DraftStore} = require 'nylas-exports'
TemplateStore = require '../lib/template-store'
fs = require 'fs-plus'
shell = require 'shell'
stubTemplatesDir = TemplateStore.templatesDirectory()
stubTemplateFiles = {
'template1.html': '<p>bla1</p>',
'template2.html': '<p>bla2</p>'
}
stubTemplates = [
{id: 'template1.html', name: 'template1', path: "#{stubTemplatesDir}/template1.html"},
{id: 'template2.html', name: 'template2', path: "#{stubTemplatesDir}/template2.html"},
]
describe "TemplateStore", ->
beforeEach ->
spyOn(fs, 'mkdir')
spyOn(shell, 'showItemInFolder').andCallFake ->
spyOn(fs, 'writeFile').andCallFake (path, contents, callback) ->
callback(null)
spyOn(fs, 'readFile').andCallFake (path, callback) ->
filename = path.split('/').pop()
callback(null, stubTemplateFiles[filename])
it "should create the templates folder if it does not exist", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(false)
TemplateStore.init()
expect(fs.mkdir).toHaveBeenCalled()
it "should expose templates in the templates directory", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true)
spyOn(fs, 'readdir').andCallFake (path, callback) -> callback(null, Object.keys(stubTemplateFiles))
TemplateStore.init()
expect(TemplateStore.items()).toEqual(stubTemplates)
it "should watch the templates directory and reflect changes", ->
watchCallback = null
watchFired = false
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true)
spyOn(fs, 'watch').andCallFake (path, callback) -> watchCallback = callback
spyOn(fs, 'readdir').andCallFake (path, callback) ->
if watchFired
callback(null, Object.keys(stubTemplateFiles))
else
callback(null, [])
TemplateStore.init()
expect(TemplateStore.items()).toEqual([])
watchFired = true
watchCallback()
expect(TemplateStore.items()).toEqual(stubTemplates)
describe "insertTemplateId", ->
it "should insert the template with the given id into the draft with the given id", ->
add = jasmine.createSpy('add')
spyOn(DraftStore, 'sessionForClientId').andCallFake ->
Promise.resolve(changes: {add})
runs ->
TemplateStore._onInsertTemplateId
templateId: 'template1.html',
draftClientId: 'localid-draft'
waitsFor ->
add.calls.length > 0
runs ->
expect(add).toHaveBeenCalledWith
body: stubTemplateFiles['template1.html']
describe "onCreateTemplate", ->
beforeEach ->
TemplateStore.init()
spyOn(DraftStore, 'sessionForClientId').andCallFake (draftClientId) ->
if draftClientId is 'localid-nosubject'
d = new Message(subject: '', body: '<p>Body</p>')
else
d = new Message(subject: 'Subject', body: '<p>Body</p>')
session =
draft: -> d
Promise.resolve(session)
it "should create a template with the given name and contents", ->
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'})
item = TemplateStore.items()?[0]
expect(item.id).toBe "123.html"
expect(item.name).toBe "123"
expect(item.path.split("/").pop()).toBe "123.html"
it "should display an error if no name is provided", ->
spyOn(TemplateStore, '_displayError')
TemplateStore._onCreateTemplate({contents: 'bla'})
expect(TemplateStore._displayError).toHaveBeenCalled()
it "should display an error if no content is provided", ->
spyOn(TemplateStore, '_displayError')
TemplateStore._onCreateTemplate({name: 'bla'})
expect(TemplateStore._displayError).toHaveBeenCalled()
it "should save the template file to the templates folder", ->
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'})
path = "#{stubTemplatesDir}/123.html"
expect(fs.writeFile).toHaveBeenCalled()
expect(fs.writeFile.mostRecentCall.args[0]).toEqual(path)
expect(fs.writeFile.mostRecentCall.args[1]).toEqual('bla')
it "should open the template so you can see it", ->
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'})
path = "#{stubTemplatesDir}/123.html"
expect(shell.showItemInFolder).toHaveBeenCalled()
describe "when given a draft id", ->
it "should create a template from the name and contents of the given draft", ->
spyOn(TemplateStore, 'trigger')
spyOn(TemplateStore, '_populate')
runs ->
TemplateStore._onCreateTemplate({draftClientId: 'localid-b'})
waitsFor ->
TemplateStore.trigger.callCount > 0
runs ->
expect(TemplateStore.items().length).toEqual(1)
it "should display an error if the draft has no subject", ->
spyOn(TemplateStore, '_displayError')
runs ->
TemplateStore._onCreateTemplate({draftClientId: 'localid-nosubject'})
waitsFor ->
TemplateStore._displayError.callCount > 0
runs ->
expect(TemplateStore._displayError).toHaveBeenCalled()
describe "onShowTemplates", ->
it "should open the templates folder in the Finder", ->
TemplateStore._onShowTemplates()
expect(shell.showItemInFolder).toHaveBeenCalled()

View file

@ -0,0 +1,41 @@
@import "ui-variables";
@import "ui-mixins";
@code-bg-color: #fcf4db;
.template-picker {
.menu {
.content-container {
height:150px;
overflow-y:scroll;
}
.footer-container {
border-top: 1px solid @border-secondary-bg;
}
}
}
.template-status-bar {
background-color: @code-bg-color;
color: darken(@code-bg-color, 70%);
border: 1.5px solid darken(@code-bg-color, 10%);
border-radius: @border-radius-small;
padding-top: @padding-small-vertical @padding-small-horizontal @padding-small-vertical @padding-small-horizontal;
font-size: @font-size-small;
}
.compose-body #contenteditable {
code.var {
font: inherit;
padding:0;
padding-left:2px;
padding-right:2px;
border-bottom: 1.5px solid darken(@code-bg-color, 10%);
background-color: fade(@code-bg-color, 10%);
&.empty {
color:darken(@code-bg-color, 70%);
border-bottom: 1px solid darken(@code-bg-color, 14%);
background-color: @code-bg-color;
}
}
}

View file

@ -5,7 +5,7 @@
"isStarterPackage": true,
"title": "Translation",
"description": "An example package for N1 that translates drafts into other languages using the Yandex API.",
"description": "Translate your drafts in the composer into other languages using the Yandex Translation API.",
"icon": "./icon.png",
"license": "Proprietary",
@ -14,7 +14,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/nylas/translate"
"url": "https://github.com/nylas/N1"
},
"windowTypes": {
"default": true,

View file

@ -5,7 +5,7 @@
"isStarterPackage": true,
"title": "Github",
"description": "Adds Github quick actions to many emails, and allows you to see the Github profiles of the people you email.",
"description": "Extends the contact card in the sidebar to show public repos of the people you email.",
"icon": "./icon.png",
"license": "MIT",

View file

@ -42,21 +42,6 @@ class ContenteditableComponent extends React.Component
@_editableNode().addEventListener('contextmenu', @_onShowContextualMenu)
@_setupSelectionListeners()
@_setupGlobalMouseListener()
@_disposable = atom.commands.add '.contenteditable-container *', {
'core:focus-next': (event) =>
editableNode = @_editableNode()
range = DOMUtils.getRangeInScope(editableNode)
for extension in DraftStore.extensions()
extension.onFocusNext(editableNode, range, event) if extension.onFocusNext
'core:focus-previous': (event) =>
editableNode = @_editableNode()
range = DOMUtils.getRangeInScope(editableNode)
for extension in DraftStore.extensions()
extension.onFocusPrevious(editableNode, range, event) if extension.onFocusPrevious
}
@_cleanHTML()
@setInnerState editableNode: @_editableNode()
@ -69,7 +54,6 @@ class ContenteditableComponent extends React.Component
@_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu)
@_teardownSelectionListeners()
@_teardownGlobalMouseListener()
@_disposable.dispose()
componentWillReceiveProps: (nextProps) =>
@_setupServices(nextProps)
@ -275,7 +259,18 @@ class ContenteditableComponent extends React.Component
@_onInput()
_onTabDown: (event) ->
editableNode = @_editableNode()
range = DOMUtils.getRangeInScope(editableNode)
for extension in DraftStore.extensions()
extension.onTabDown(editableNode, range, event) if extension.onTabDown
return if event.defaultPrevented
@_onTabDownDefaultBehavior(event)
_onTabDownDefaultBehavior: (event) ->
event.preventDefault()
selection = document.getSelection()
if selection?.isCollapsed
# Only Elements (not Text nodes) have the `closest` method

View file

@ -118,29 +118,14 @@ class DraftStoreExtension
@onMouseUp: (editableNode, range, event) ->
return
###
Public: Called when the user presses `Shift-Tab` while focused on the composer's body field.
Override onFocusPrevious in your DraftStoreExtension to adjust the selection or perform
other actions. If your package implements Shift-Tab behavior in a particular scenario, you
should prevent the default behavior of Shift-Tab via `event.preventDefault()`.
- `editableNode` The composer's contenteditable {Node} that received the event.
- `range`: The currently selected {Range} in the `editableNode`
- `event`: The mouse up event.
###
@onFocusPrevious: (editableNode, range, event) ->
return
###
Public: Called when the user presses `Tab` while focused on the composer's body field.
Override onFocusPrevious in your DraftStoreExtension to adjust the selection or perform
Override onTabDown in your DraftStoreExtension to adjust the selection or perform
other actions. If your package implements Tab behavior in a particular scenario, you
should prevent the default behavior of Tab via `event.preventDefault()`.
Important: You should prevent the default tab behavior with great care.
- `editableNode` The composer's contenteditable {Node} that received the event.
- `range`: The currently selected {Range} in the `editableNode`
@ -148,7 +133,7 @@ class DraftStoreExtension
- `event`: The mouse up event.
###
@onFocusNext: (editableNode, range, event) ->
@onTabDown: (editableNode, range, event) ->
return
###

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB