├── .babelrc ├── .eslintrc ├── .gitignore ├── AtlassianSketchFramework ├── AtlassianSketchFramework.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata ├── AtlassianSketchFramework │ ├── AtlassianBaseRequestDelegate.m │ ├── AtlassianRequestDelegates.h │ ├── AtlassianSketchFramework.h │ ├── AtlassianURLConnectionDelegate.m │ ├── AtlassianURLDownloadDelegate.m │ └── Info.plist └── AtlassianSketchFrameworkTests │ ├── AtlassianSketchFrameworkTests.m │ └── Info.plist ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── TROUBLESHOOTING.md ├── appcast.xml ├── bin ├── docker-env.sh ├── rm-prefs.sh ├── set-pref.sh ├── show-prefs.sh └── unlink-plugin.sh ├── bitbucket-pipelines.sh ├── bitbucket-pipelines.yml ├── build.sh ├── jira.sketchplugin └── Contents │ ├── Resources │ ├── AtlassianSketchFramework.framework │ │ ├── AtlassianSketchFramework │ │ ├── Headers │ │ │ └── AtlassianSketchFramework.h │ │ ├── Modules │ │ │ └── module.modulemap │ │ ├── Resources │ │ │ └── Info.plist │ │ └── Versions │ │ │ ├── A │ │ │ ├── AtlassianSketchFramework │ │ │ ├── Headers │ │ │ │ └── AtlassianSketchFramework.h │ │ │ ├── Modules │ │ │ │ └── module.modulemap │ │ │ └── Resources │ │ │ │ └── Info.plist │ │ │ └── Current │ │ │ ├── AtlassianSketchFramework │ │ │ ├── Headers │ │ │ └── AtlassianSketchFramework.h │ │ │ ├── Modules │ │ │ └── module.modulemap │ │ │ └── Resources │ │ │ └── Info.plist │ ├── atlassian.svg │ ├── config.json │ ├── connect.html │ ├── connect.js │ ├── issues.html │ ├── issues.js │ ├── jira-icon-runner.png │ ├── jira-icon.png │ ├── replace.svg │ ├── upload-alert.png │ ├── upload-icon.png │ └── upload.png │ └── Sketch │ ├── jira.js │ ├── manifest.json │ ├── on-selection-or-export-change.js │ └── startup.js ├── package-lock.json ├── package.json ├── s3_upload.py ├── screenshots.png ├── src ├── analytics.js ├── auth.js ├── blank-thumbnail-datauri.txt ├── commands │ ├── README.md │ ├── jira.js │ ├── on-selection-or-export-change.js │ └── startup.js ├── config.js ├── default-imports.js ├── entity-mappers.js ├── error │ ├── AuthorizationError.js │ ├── FaqError.js │ └── README.md ├── export.js ├── frameworks │ ├── AtlassianSketchFramework.js │ └── README.md ├── jira.js ├── jql-filters.js ├── logger.js ├── manifest.json ├── pasteboard.js ├── plugin-state.js ├── prefs.js ├── properties.js ├── request.js ├── token-cache.js ├── upgrade │ ├── 000_addon_url_to_sketch_atlassian_com.js │ ├── README.md │ └── upgrade.js ├── util.js └── views │ ├── alerts │ ├── README.md │ └── keep-or-replace.js │ ├── bridge │ ├── README.md │ ├── client.js │ ├── common.js │ └── host.js │ ├── controls │ ├── README.md │ ├── button-delegate.js │ └── export-button.js │ ├── panels │ ├── README.md │ ├── connect.js │ ├── helpers │ │ ├── attachments.js │ │ ├── filters.js │ │ └── uploads.js │ ├── issues.js │ ├── launch.js │ ├── panel-delegate.js │ ├── ui-constants.js │ └── webui-common.js │ └── web │ ├── README.md │ ├── connect │ ├── README.md │ ├── connect.js │ └── model │ │ └── index.js │ ├── issues │ ├── README.md │ ├── components │ │ ├── AssigneeAvatar.js │ │ ├── Attachment.js │ │ ├── Attachments.js │ │ ├── Breadcrumbs.js │ │ ├── CommentEditor.js │ │ ├── Comments.js │ │ ├── DropZone.js │ │ ├── IssueFilter.js │ │ ├── IssueList.js │ │ ├── IssueView.js │ │ └── SettingsMenu.js │ ├── issues.js │ └── model │ │ ├── Attachment.js │ │ ├── CommentEditor.js │ │ ├── Filter.js │ │ ├── Issue.js │ │ ├── Profile.js │ │ ├── index.js │ │ ├── mapper.js │ │ └── thumbnails.js │ └── util.js ├── test ├── unit │ └── util.test.j_ └── web │ ├── issues.json │ ├── issues.test.js │ └── profile.json ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-1"], 3 | "plugins": ["transform-decorators-legacy", "babel-plugin-add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - standard 4 | - standard-react 5 | - plugin:import/warnings 6 | - plugin:import/errors 7 | - sketch 8 | parser: babel-eslint 9 | parserOptions: 10 | ecmaVersion: 2017 11 | sourceType: module 12 | ecmaFeatures: 13 | jsx: true 14 | globals: 15 | AtlassianSketchFramework: false 16 | AtlassianURLConnectionDelegate: false 17 | AtlassianURLDownloadDelegate: false 18 | COScript: false 19 | coscript: false 20 | log: false 21 | MOClassDescription: false 22 | MOPointer: false 23 | MSImmutableColor: false 24 | Mocha: false 25 | NSASCIIStringEncoding: false 26 | NSAlertFirstButtonReturn: false 27 | NSAlertSecondButtonReturn: false 28 | NSAppKitVersionNumber: false 29 | NSAppKitVersionNumber10_12: false 30 | NSClassFromString: false 31 | NSClosableWindowMask: false 32 | NSDownloadsDirectory: false 33 | NSDragPboard: false 34 | NSFileHandleReadToEndOfFileCompletionNotification: false 35 | NSFileManager: false 36 | NSFileSize: false 37 | NSImageOnly: false 38 | NSImageScaleProportionallyDown: false 39 | NSInputStream: false 40 | NSModalResponseCancel: false 41 | NSMutableURLRequest: false 42 | NSNotificationCenter: false 43 | NSOperationQueue: false 44 | NSOutputStream: false 45 | NSPasteboard: false 46 | NSRoundedBezelStyle: false 47 | NSSelectorFromString: false 48 | NSTaskDidTerminateNotification: false 49 | NSTemporaryDirectory: false 50 | NSThread: false 51 | NSTitledWindowMask: false 52 | NSURLConnection: false 53 | NSURLDownload: false 54 | NSURLSession: false 55 | NSURLSessionConfiguration: false 56 | NSUUID: false 57 | NSUserDomainMask: false 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artefacts 2 | jira.sketchplugin/Contents/**/*.js 3 | jira.sketchplugin/Contents/**/*.json 4 | !jira.sketchplugin/Contents/**/config.json 5 | 6 | # npm 7 | node_modules 8 | .npm 9 | npm-debug.log 10 | 11 | # mac 12 | .DS_Store 13 | .vscode/ 14 | 15 | ## Build generated 16 | build/ 17 | DerivedData/ 18 | 19 | ## Various settings 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | xcuserdata/ 29 | 30 | ## Other 31 | *.moved-aside 32 | *.xccheckout 33 | *.xcscmblueprint 34 | 35 | ### Xcode Patch ### 36 | *.xcodeproj/* 37 | !*.xcodeproj/project.pbxproj 38 | !*.xcodeproj/xcshareddata/ 39 | !*.xcworkspace/contents.xcworkspacedata 40 | /*.gcno 41 | 42 | ## symlink to Sketch plugins directory 43 | sketch-plugins 44 | 45 | ## Test artifacts 46 | xvfb-run*/ 47 | .org.chromium.Chromium*/ 48 | .com.google.Chrome*/ 49 | 50 | ## Build artifacts 51 | jira.sketchplugin-*.zip 52 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/AtlassianBaseRequestDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianBaseRequestDelegate.m 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/25/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import "AtlassianRequestDelegates.h" 10 | 11 | @implementation AtlassianBaseRequestDelegate 12 | 13 | - (instancetype)init 14 | { 15 | self = [super init]; 16 | self.progress = [[NSProgress alloc] initWithParent:[NSProgress currentProgress] userInfo:nil]; 17 | return self; 18 | } 19 | 20 | @end 21 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/AtlassianRequestDelegates.h: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianURLDelegates.h 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/25/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface AtlassianBaseRequestDelegate : NSObject 12 | 13 | @property (readwrite, atomic, strong) NSProgress * progress; 14 | @property (readwrite, atomic, strong) NSError * error; 15 | @property (readwrite, atomic, strong) NSHTTPURLResponse * response; 16 | @property (readwrite, atomic) bool completed; 17 | @property (readwrite, atomic) bool failed; 18 | 19 | @end 20 | 21 | @interface AtlassianURLConnectionDelegate : AtlassianBaseRequestDelegate 22 | 23 | @property (readwrite, atomic, strong) NSMutableData * data; 24 | 25 | @end 26 | 27 | @interface AtlassianURLDownloadDelegate : AtlassianBaseRequestDelegate 28 | 29 | @property (readwrite, atomic, strong) NSString * filePath; 30 | 31 | @end 32 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/AtlassianSketchFramework.h: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianSketchFramework.h 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/20/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | 12 | //! Project version number for AtlassianSketchFramework. 13 | FOUNDATION_EXPORT double AtlassianSketchFrameworkVersionNumber; 14 | 15 | //! Project version string for AtlassianSketchFramework. 16 | FOUNDATION_EXPORT const unsigned char AtlassianSketchFrameworkVersionString[]; 17 | 18 | // In this header, you should import all the public headers of your framework using statements like #import 19 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/AtlassianURLConnectionDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianURLConnectionDelegate.m 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/21/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import "AtlassianRequestDelegates.h" 10 | 11 | @implementation AtlassianURLConnectionDelegate : AtlassianBaseRequestDelegate 12 | 13 | - (void)connection:(NSURLConnection __unused *)connection 14 | didSendBodyData:(NSInteger)bytesWritten 15 | totalBytesWritten:(NSInteger)totalBytesWritten 16 | totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite 17 | { 18 | NSProgress *progress = self.progress; 19 | progress.totalUnitCount = totalBytesExpectedToWrite; 20 | progress.completedUnitCount = totalBytesWritten; 21 | } 22 | 23 | - (void)connection:(NSURLConnection __unused *)connection 24 | didReceiveResponse:(NSURLResponse *)response 25 | { 26 | self.response = (NSHTTPURLResponse*) response; 27 | self.data = [[ NSMutableData alloc ] init ]; 28 | } 29 | 30 | - (void)connection:(NSURLConnection __unused *)connection 31 | didReceiveData:(NSData *)data 32 | { 33 | [ self.data appendData:data ]; 34 | } 35 | 36 | - (void)connectionDidFinishLoading:(NSURLConnection __unused *)connection 37 | { 38 | self.completed = YES; 39 | } 40 | 41 | - (void)connection:(NSURLConnection __unused *)connection 42 | didFailWithError:(NSError *)error 43 | { 44 | self.error = error; 45 | self.failed = YES; 46 | } 47 | 48 | @end 49 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/AtlassianURLDownloadDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianNSURLDownloadDelegate.m 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/25/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import "AtlassianRequestDelegates.h" 10 | 11 | @implementation AtlassianURLDownloadDelegate : AtlassianBaseRequestDelegate 12 | 13 | - (void)download:(NSURLDownload __unused *)download 14 | didReceiveResponse:(NSURLResponse *)response 15 | { 16 | self.response = (NSHTTPURLResponse*) response; 17 | self.progress.totalUnitCount = response.expectedContentLength; 18 | } 19 | 20 | - (void)download:(NSURLDownload __unused *)download 21 | didCreateDestination:(NSString *)path 22 | { 23 | self.filePath = path; 24 | } 25 | 26 | - (void)download:(NSURLDownload __unused *)download 27 | didReceiveDataOfLength:(NSUInteger)length 28 | { 29 | self.progress.completedUnitCount += length; 30 | } 31 | 32 | - (void)downloadDidFinish:(NSURLDownload __unused *)download 33 | { 34 | self.completed = YES; 35 | } 36 | 37 | - (void)download:(NSURLDownload __unused *)download 38 | didFailWithError:(NSError *)error 39 | { 40 | self.error = error; 41 | self.failed = YES; 42 | } 43 | 44 | @end 45 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFramework/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSHumanReadableCopyright 22 | Copyright © 2017 Atlassian. All rights reserved. 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFrameworkTests/AtlassianSketchFrameworkTests.m: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianSketchFrameworkTests.m 3 | // AtlassianSketchFrameworkTests 4 | // 5 | // Created by Tim Pettersen on 7/20/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface AtlassianSketchFrameworkTests : XCTestCase 12 | 13 | @end 14 | 15 | @implementation AtlassianSketchFrameworkTests 16 | 17 | - (void)setUp { 18 | [super setUp]; 19 | // Put setup code here. This method is called before the invocation of each test method in the class. 20 | } 21 | 22 | - (void)tearDown { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | [super tearDown]; 25 | } 26 | 27 | - (void)testExample { 28 | // This is an example of a functional test case. 29 | // Use XCTAssert and related functions to verify your tests produce the correct results. 30 | } 31 | 32 | - (void)testPerformanceExample { 33 | // This is an example of a performance test case. 34 | [self measureBlock:^{ 35 | // Put the code you want to measure the time of here. 36 | }]; 37 | } 38 | 39 | @end 40 | -------------------------------------------------------------------------------- /AtlassianSketchFramework/AtlassianSketchFrameworkTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported 58 | by contacting a project maintainer. Complaints will result in a response and be 59 | reviewed and investigated in a way that is deemed necessary and appropriate to the 60 | circumstances. Maintainers are obligated to maintain confidentiality with regard to the 61 | reporter of an incident. 62 | 63 | Project maintainers who do not follow or enforce the Code of Conduct in good 64 | faith may face temporary or permanent repercussions as determined by other 65 | members of the project's leadership. 66 | 67 | ## Attribution 68 | 69 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 70 | available at [http://contributor-covenant.org/version/1/4][version] 71 | 72 | [homepage]: http://contributor-covenant.org 73 | [version]: http://contributor-covenant.org/version/1/4/ 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build environment for bitbucket-pipelines.yml 2 | FROM node:7.10.0 3 | 4 | ENV DEBIAN_FRONTEND=noninteractive LANG=en_US.UTF-8 LC_ALL=C.UTF-8 \ 5 | LANGUAGE=en_US.UTF-8 TERM=dumb DBUS_SESSION_BUS_ADDRESS=/dev/null \ 6 | CHROME_VERSION=stable_current \ 7 | SCREEN_WIDTH=1360 SCREEN_HEIGHT=1020 SCREEN_DEPTH=24 8 | 9 | RUN rm -rf /var/lib/apt/lists/* && apt-get -q update && \ 10 | apt-get install -qy --force-yes \ 11 | xvfb fontconfig bzip2 curl libxss1 libappindicator1 libindicator7 \ 12 | libpango1.0-0 fonts-liberation xdg-utils gconf-service zip python-pip \ 13 | python-dev lsb-release libnss3 libnspr4 libgtk-3-0 libasound2 && \ 14 | apt-get clean && \ 15 | rm -rf /var/lib/apt/lists/* && \ 16 | rm -rf /tmp/* 17 | 18 | # Python dependencies & jq (used in bitbucket-pipelines.sh) 19 | RUN apt-get update && apt-get install -y zip python-pip python-dev jq 20 | RUN pip install boto3 21 | 22 | # npm dependencies 23 | RUN npm install -g skpm@0.9.16 bitbucket-build-status@1.0.3 24 | 25 | # Install Chrome 26 | RUN curl --silent --show-error --location --fail --retry 3 \ 27 | https://dl.google.com/linux/direct/google-chrome-${CHROME_VERSION}_amd64.deb > /tmp/google-chrome-${CHROME_VERSION}_amd64.deb && \ 28 | dpkg -i /tmp/google-chrome-${CHROME_VERSION}_amd64.deb && \ 29 | rm /tmp/google-chrome-${CHROME_VERSION}_amd64.deb 30 | 31 | # Override Chrome launcher script to run xvfb 32 | RUN mv /opt/google/chrome/google-chrome /opt/google/chrome/google-chrome.orig && \ 33 | echo '#!/bin/bash' > /opt/google/chrome/google-chrome && \ 34 | echo 'exec xvfb-run -a -s "-screen 0 ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH} -ac +extension RANDR" /opt/google/chrome/google-chrome.orig --no-sandbox "$@"' >> /opt/google/chrome/google-chrome && \ 35 | chmod +x /opt/google/chrome/google-chrome 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Atlassian Pty Ltd 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Guide 2 | 3 | Like many Sketch plugins, Jira Cloud for Sketch indulges in some slightly risky behaviour in order to provide a more compelling user experience. This includes: 4 | 5 | 1. Invoking Sketch's internal classes directly 6 | 2. Invoking classes provided by some of Sketch's dependencies (i.e. AFNetworking) 7 | 3. Modifying properties of NSView instances created by Sketch 8 | 4. Adding new subviews to NSView instances created by Sketch 9 | 5. Using deprecated Cocoa classes 10 | 11 | Items 1 through 4 may potentially break after a Sketch upgrade, if the developers change or remove a class, dependency, or UI element that we depend on. Item 5 is less likely to cause issues, but may eventually break due to a macOS update that removes a deprecated class that we depend on. 12 | 13 | We've made some effort to minimize the risk of failure, and to degrade gracefully (i.e. avoid crashing Sketch) in case of failure. However, depending on the nature of the underlying change, the failure mode may vary. This guide is designed to help an end user or support engineer remedy problems if they occur. 14 | 15 | ## Symptoms & remedies 16 | 17 | ### Sketch starts, but the 'Export to Jira' button doesn't appear in the 'Export' panel 18 | 19 | If the user has recently upgraded Sketch, the UI layout may have changed in such a way that the plugin can no longer find the correct `NSView` to add the 'Export to Jira' button to. As a workaround, it should still be possible to export images by using the 'Jira' item in the 'Plugin' menu, or by pressing `⌘+⌥+J`. Please also [raise an issue] for the plugin maintainers to investigate. 20 | 21 | ### Sketch starts, but the Jira button doesn't appear in the menu 22 | 23 | This usually indicates that the plugin isn't actually installed. Please try [downloading and re-installing the plugin]. If symptoms persist, [raise an issue] for the plugin maintainers to investigate. 24 | 25 | ### Sketch fails to start, or crashes on startup 26 | 27 | This may be a problem caused by a Sketch plugin. To determine which plugin is causing the issue, try removing them one-by-one from the Sketch plugin's directory at `~/Library/Application\ Support/com.bohemiancoding.sketch3/Plugins/` and reproducing the crash. If the offending plugin is Jira Cloud for Sketch, please [raise an issue] for the plugin maintainers to investigate. 28 | 29 | If the broken plugin is critical to your workflow, you may downgrade Sketch by [downloading and installing an earlier version]. 30 | 31 | ### Sketch starts, but some of the plugin's functionality is broken 32 | 33 | This could indicate that one of the Objective-C classes depended on by the plugin has changed, or a bug in the plugin. Please [raise an issue] for the plugin maintainers to investigate. You can also try the other troubleshooting steps below. 34 | 35 | ## Other troubleshooting steps 36 | 37 | ### Check your Jira Cloud permissions 38 | 39 | The Sketch plugin tries hard to determine if a request to Jira Cloud failed due to a permissions issue and display an appropriate error. However, different Jira Cloud REST resources have different failure modes depending on the user's permissions, so there's a chance that the plugin will misinterpret a returned error code. Check with your Jira Cloud administrator that you have the [appropriate permissions] for the operation that is failing. 40 | 41 | ### Check the logs 42 | 43 | Plugin logs are written to `~/Library/Logs/com.bohemiancoding.sketch3/Plugin Output.log`. This file is cleared by Sketch on startup, so be sure to reproduce the issue before capturing the logs. 44 | 45 | ### Check for client-side JavaScript errors 46 | 47 | The plugin's UI is provided by a Safari WebView. You can view errors logged to the Safari console by: 48 | 49 | 1. Enabling the [Safari Develop menu] 50 | 2. Right-clicking on the Jira panel and selecting 'Inspect Element' 51 | 3. Clicking on `Console` 52 | 53 | Warnings and errors will be displayed in yellow and red, respectively. Note that there are a couple of known but bengign issues with a couple of the plugin's dependencies. Warnings about PropTypes and failures to load `.js.map` files are usually safe to ignore. 54 | 55 | ### Clear plugin settings 56 | 57 | Plugin settings are stored in `~/Library/Preferences/plugin.sketch.jira-sketch-plugin.plist`. You can view these settings with the following command: 58 | 59 | `/usr/bin/defaults read ~/Library/Preferences/plugin.sketch.jira-sketch-plugin` 60 | 61 | Or clear these settings with: 62 | 63 | `/usr/bin/defaults delete ~/Library/Preferences/plugin.sketch.jira-sketch-plugin` 64 | 65 | NOTE: the `authToken` stored in the `plist` is a temporary bearer token valid for a short period (~15 minutes, at time of writing) from creation. Do not share tokens that are still valid with others, or post them on the Internet, etc. 66 | 67 | ### Verify or tweak configuration settings 68 | 69 | Various configuration settings (log levels, refresh intervals, etc.) can be modified by editing `jira.sketchplugin/Contents/Resources/config.json`. 70 | 71 | ### Create a development build 72 | 73 | To aid in diagnosis, you may wish to add additional logging to the plugin and create a development build. Check `README.md` for a guide to building the plugin. 74 | 75 | Or, if you're feeling brave, you can modify the transpiled CocoaScript in `jira.sketchplugin/Contents/Sketch/*.js` or transpiled JavaScript in `jira.sketchplugin/Contents/Resources/*.js`. Note that you'll need to restart Sketch to pick up any changes. 76 | 77 | [raise an issue]: https://github.com/atlassian/jira-cloud-for-sketch/issues 78 | [downloading and re-installing the plugin]: https://sketch.atlassian.com 79 | [downloading and installing an earlier version]: https://www.sketchapp.com/updates/ 80 | [appropriate permissions]: https://sketch.atlassian.com/faq#no-permission 81 | [Safari Develop menu]: https://apple.stackexchange.com/a/139771 82 | -------------------------------------------------------------------------------- /bin/docker-env.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | DOCKER_IMAGE=`cat bitbucket-pipelines.yml | head -1 | cut -c 8-` 3 | docker run --name sketch_bash -v `pwd`:/jira-sketch-plugin --rm -it $DOCKER_IMAGE /bin/bash 4 | -------------------------------------------------------------------------------- /bin/rm-prefs.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | /usr/bin/defaults delete ~/Library/Preferences/plugin.sketch.jira-sketch-plugin 3 | -------------------------------------------------------------------------------- /bin/set-pref.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | if [ "$#" -ne 2 ]; then 3 | echo 'Usage set-pref.sh ' 4 | exit 1 5 | fi 6 | /usr/bin/defaults write ~/Library/Preferences/plugin.sketch.jira-sketch-plugin $1 $2 7 | -------------------------------------------------------------------------------- /bin/show-prefs.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | /usr/bin/defaults read ~/Library/Preferences/plugin.sketch.jira-sketch-plugin 3 | -------------------------------------------------------------------------------- /bin/unlink-plugin.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | rm -r ~/Library/Application\ Support/com.bohemiancoding.sketch3/Plugins/jira-cloud-for-sketch/ 3 | -------------------------------------------------------------------------------- /bitbucket-pipelines.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install dependencies (there's a few!) 4 | npm install 5 | 6 | # Note: the AtlassianSketchFramework is written in Objective-C and can not be 7 | # built in Bitbucket Pipelines' Docker environment. Therefore if you make 8 | # changes to /AtlassianSketchFramework, make sure you rebuild the project locally 9 | # using XCode (or `./build.sh`) before committing! 10 | 11 | # build the plugin 12 | skpm build 13 | 14 | # run the tests 15 | npm test 16 | 17 | export PLUGIN_NAME="jira.sketchplugin" 18 | export PLUGIN_ZIP="${PLUGIN_NAME}-${BITBUCKET_COMMIT}.zip" 19 | export S3_BUCKET="atlassian-sketch-plugin" 20 | export S3_BUCKET_URL="https://s3-us-west-2.amazonaws.com/${S3_BUCKET}" 21 | 22 | # get access token for posting build statuses and pushing to Bitbucket 23 | export BITBUCKET_ACCESS_TOKEN=`curl -s -X POST -u "${BB_CONSUMER_KEY}:${BB_CONSUMER_SECRET}" https://bitbucket.org/site/oauth2/access_token -d grant_type=client_credentials | jq .access_token -r` 24 | 25 | zip -r $PLUGIN_ZIP $PLUGIN_NAME 26 | 27 | # postBuildStatus $key $name $description $url 28 | postBuildStatus () { 29 | node_modules/.bin/bbuild \ 30 | -b ${BITBUCKET_ACCESS_TOKEN} \ 31 | -o ${BITBUCKET_REPO_OWNER} \ 32 | -r ${BITBUCKET_REPO_SLUG} \ 33 | -c ${BITBUCKET_COMMIT} \ 34 | -s "SUCCESSFUL" \ 35 | -k "$1" -n "$2" -d "$3" -l "$4" 36 | } 37 | 38 | # deploy plugin artifact(s) 39 | python s3_upload.py $S3_BUCKET $PLUGIN_ZIP $PLUGIN_ZIP 40 | postBuildStatus "sketch-plugin-zip" $PLUGIN_ZIP \ 41 | "Sketch plugin zip" "${S3_BUCKET_URL}/${PLUGIN_ZIP}" 42 | 43 | if [ -v PIPELINES_DEPLOY_AS_LATEST ]; then 44 | python s3_upload.py $S3_BUCKET $PLUGIN_ZIP "${PLUGIN_NAME}-latest.zip" 45 | fi 46 | 47 | if [ -v PIPELINES_DEPLOY_TAG ]; then 48 | export PLUGIN_RELEASE_ZIP="${PLUGIN_NAME}-$BITBUCKET_TAG.zip" 49 | python s3_upload.py $S3_BUCKET $PLUGIN_ZIP $PLUGIN_RELEASE_ZIP 50 | postBuildStatus "sketch-plugin-release-zip" $PLUGIN_RELEASE_ZIP \ 51 | "Sketch plugin release zip" "${S3_BUCKET_URL}/${PLUGIN_RELEASE_ZIP}" 52 | python s3_upload.py $S3_BUCKET $PLUGIN_ZIP "${PLUGIN_NAME}.zip" 53 | python s3_upload.py $S3_BUCKET appcast.xml appcast.xml 54 | 55 | # update settings for write access 56 | git remote set-url origin https://x-token-auth:${BITBUCKET_ACCESS_TOKEN}@bitbucket.org/${BITBUCKET_REPO_OWNER}/${BITBUCKET_REPO_SLUG}.git 57 | git config user.name "${COMMITTER_NAME}" 58 | git config user.email "${COMMITTER_EMAIL}" 59 | 60 | # switch to release branch without touching index / working copy 61 | git fetch origin $BITBUCKET_RELEASE_BRANCH:$BITBUCKET_RELEASE_BRANCH 62 | git reset $BITBUCKET_RELEASE_BRANCH 63 | git checkout $BITBUCKET_RELEASE_BRANCH 64 | 65 | # commit source from tag plus compiled plugin files 66 | git add . 67 | git add -f $PLUGIN_NAME 68 | git commit -m "Build v$BITBUCKET_TAG" 69 | 70 | # push to Bitbucket & GitHub 71 | git push origin $BITBUCKET_RELEASE_BRANCH 72 | git push -f $GITHUB_SSH_URL $BITBUCKET_RELEASE_BRANCH:$GITHUB_RELEASE_BRANCH 73 | fi 74 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | image: kannonboy/atlassian-sketch-plugin:0.0.7 2 | 3 | pipelines: 4 | branches: 5 | develop: 6 | - step: 7 | caches: 8 | - node 9 | script: 10 | - PIPELINES_DEPLOY_AS_LATEST=1 ./bitbucket-pipelines.sh 11 | 12 | tags: 13 | '**': 14 | - step: 15 | caches: 16 | - node 17 | script: 18 | - PIPELINES_DEPLOY_TAG=1 ./bitbucket-pipelines.sh 19 | 20 | default: 21 | - step: 22 | caches: 23 | - node 24 | script: 25 | - ./bitbucket-pipelines.sh 26 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pushd AtlassianSketchFramework 3 | xcodebuild -scheme AtlassianSketchFramework build 4 | popd 5 | skpm build 6 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/AtlassianSketchFramework: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/AtlassianSketchFramework -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Headers/AtlassianSketchFramework.h: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianSketchFramework.h 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/20/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | 12 | //! Project version number for AtlassianSketchFramework. 13 | FOUNDATION_EXPORT double AtlassianSketchFrameworkVersionNumber; 14 | 15 | //! Project version string for AtlassianSketchFramework. 16 | FOUNDATION_EXPORT const unsigned char AtlassianSketchFrameworkVersionString[]; 17 | 18 | // In this header, you should import all the public headers of your framework using statements like #import 19 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Modules/module.modulemap: -------------------------------------------------------------------------------- 1 | framework module AtlassianSketchFramework { 2 | umbrella header "AtlassianSketchFramework.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 16E195 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | AtlassianSketchFramework 11 | CFBundleIdentifier 12 | com.atlassian.AtlassianSketchFramework 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | AtlassianSketchFramework 17 | CFBundlePackageType 18 | FMWK 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSupportedPlatforms 22 | 23 | MacOSX 24 | 25 | CFBundleVersion 26 | 1 27 | DTCompiler 28 | com.apple.compilers.llvm.clang.1_0 29 | DTPlatformBuild 30 | 8E3004b 31 | DTPlatformVersion 32 | GM 33 | DTSDKBuild 34 | 16E185 35 | DTSDKName 36 | macosx10.12 37 | DTXcode 38 | 0833 39 | DTXcodeBuild 40 | 8E3004b 41 | NSHumanReadableCopyright 42 | Copyright © 2017 Atlassian. All rights reserved. 43 | 44 | 45 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/A/AtlassianSketchFramework: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/A/AtlassianSketchFramework -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/A/Headers/AtlassianSketchFramework.h: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianSketchFramework.h 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/20/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | 12 | //! Project version number for AtlassianSketchFramework. 13 | FOUNDATION_EXPORT double AtlassianSketchFrameworkVersionNumber; 14 | 15 | //! Project version string for AtlassianSketchFramework. 16 | FOUNDATION_EXPORT const unsigned char AtlassianSketchFrameworkVersionString[]; 17 | 18 | // In this header, you should import all the public headers of your framework using statements like #import 19 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/A/Modules/module.modulemap: -------------------------------------------------------------------------------- 1 | framework module AtlassianSketchFramework { 2 | umbrella header "AtlassianSketchFramework.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/A/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 16E195 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | AtlassianSketchFramework 11 | CFBundleIdentifier 12 | com.atlassian.AtlassianSketchFramework 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | AtlassianSketchFramework 17 | CFBundlePackageType 18 | FMWK 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSupportedPlatforms 22 | 23 | MacOSX 24 | 25 | CFBundleVersion 26 | 1 27 | DTCompiler 28 | com.apple.compilers.llvm.clang.1_0 29 | DTPlatformBuild 30 | 8E3004b 31 | DTPlatformVersion 32 | GM 33 | DTSDKBuild 34 | 16E185 35 | DTSDKName 36 | macosx10.12 37 | DTXcode 38 | 0833 39 | DTXcodeBuild 40 | 8E3004b 41 | NSHumanReadableCopyright 42 | Copyright © 2017 Atlassian. All rights reserved. 43 | 44 | 45 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/Current/AtlassianSketchFramework: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/Current/AtlassianSketchFramework -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/Current/Headers/AtlassianSketchFramework.h: -------------------------------------------------------------------------------- 1 | // 2 | // AtlassianSketchFramework.h 3 | // AtlassianSketchFramework 4 | // 5 | // Created by Tim Pettersen on 7/20/17. 6 | // Copyright © 2017 Atlassian. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | 12 | //! Project version number for AtlassianSketchFramework. 13 | FOUNDATION_EXPORT double AtlassianSketchFrameworkVersionNumber; 14 | 15 | //! Project version string for AtlassianSketchFramework. 16 | FOUNDATION_EXPORT const unsigned char AtlassianSketchFrameworkVersionString[]; 17 | 18 | // In this header, you should import all the public headers of your framework using statements like #import 19 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/Current/Modules/module.modulemap: -------------------------------------------------------------------------------- 1 | framework module AtlassianSketchFramework { 2 | umbrella header "AtlassianSketchFramework.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/AtlassianSketchFramework.framework/Versions/Current/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 16E195 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | AtlassianSketchFramework 11 | CFBundleIdentifier 12 | com.atlassian.AtlassianSketchFramework 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | AtlassianSketchFramework 17 | CFBundlePackageType 18 | FMWK 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSupportedPlatforms 22 | 23 | MacOSX 24 | 25 | CFBundleVersion 26 | 1 27 | DTCompiler 28 | com.apple.compilers.llvm.clang.1_0 29 | DTPlatformBuild 30 | 8E3004b 31 | DTPlatformVersion 32 | GM 33 | DTSDKBuild 34 | 16E185 35 | DTSDKName 36 | macosx10.12 37 | DTXcode 38 | 0833 39 | DTXcodeBuild 40 | 8E3004b 41 | NSHumanReadableCopyright 42 | Copyright © 2017 Atlassian. All rights reserved. 43 | 44 | 45 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/atlassian.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | atlassian 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "logLevel": 10, 3 | "jiraSketchIntegrationBaseUrl": "https://sketch.atlassian.com", 4 | "analyticsApiBaseUrl": "https://mgas.prod.public.atl-paas.net/v1", 5 | "bearerTokenExpirySafetyMargin": 60, 6 | "bearerTokenRefreshInterval": 600000, 7 | "thumbnailDownloadConcurrency": 4, 8 | "attachmentUploadConcurrency": 4, 9 | "thumbnailRetryMax": 25, 10 | "thumbnailRetryDelay": 600, 11 | "userAuthorizationPollInterval": 2000, 12 | "cocoaDelegatePollInterval": 200, 13 | "jiraAuthorizationUrlRetryInterval": 3000, 14 | "jiraAuthorizationUrlMaxRetries": 3, 15 | "maxUserPickerResults": 20 16 | } 17 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/connect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Connect 6 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/issues.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Issues 6 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/jira-icon-runner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/jira-icon-runner.png -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/jira-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/jira-icon.png -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/replace.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/upload-alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/upload-alert.png -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/upload-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/upload-icon.png -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Resources/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/jira.sketchplugin/Contents/Resources/upload.png -------------------------------------------------------------------------------- /jira.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jira Cloud for Sketch", 3 | "author": "Atlassian", 4 | "email": "tim@atlassian.com", 5 | "identifier": "com.atlassian.sketch.jira-plugin", 6 | "appcast": "https://s3-us-west-2.amazonaws.com/atlassian-sketch-plugin/appcast.xml", 7 | "compatibleVersion": 3, 8 | "bundleVersion": 1, 9 | "commands": [ 10 | { 11 | "name": "Jira", 12 | "identifier": "jira", 13 | "script": "jira.js", 14 | "shortcut": "cmd alt j", 15 | "description": "Open the 'Export to Jira Cloud' panel.", 16 | "icon": "jira-icon-runner.png" 17 | }, 18 | { 19 | "name": "StartUp", 20 | "identifier": "startup", 21 | "script": "startup.js", 22 | "handlers": { 23 | "actions": { 24 | "Startup": "onRun" 25 | } 26 | } 27 | }, 28 | { 29 | "name": "On Selection or Export Change", 30 | "identifier": "on-selection-or-export-change", 31 | "script": "on-selection-or-export-change.js", 32 | "handlers": { 33 | "actions": { 34 | "AddExportFormat": "onRun", 35 | "SelectionChanged": "onRun" 36 | } 37 | } 38 | } 39 | ], 40 | "menu": { 41 | "isRoot": true, 42 | "items": [ 43 | "jira" 44 | ] 45 | }, 46 | "version": "1.1.3", 47 | "description": "Share designs, assets, and feedback with your team", 48 | "homepage": "https://sketch.atlassian.com", 49 | "disableCocoaScriptPreprocessor": true 50 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-cloud-for-sketch", 3 | "version": "1.1.3", 4 | "description": "Share designs, assets, and feedback with your team", 5 | "main": "jira.sketchplugin", 6 | "manifest": "src/manifest.json", 7 | "resources": [ 8 | "src/views/web/connect/connect.js", 9 | "src/views/web/issues/issues.js" 10 | ], 11 | "scripts": { 12 | "test": "ava", 13 | "postinstall": "npm run build && skpm link ./", 14 | "build": "skpm build", 15 | "publish": "skpm publish" 16 | }, 17 | "ava": { 18 | "files": [ 19 | "test/**/*.js" 20 | ], 21 | "require": [ 22 | "babel-register" 23 | ], 24 | "babel": "inherit" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://bitbucket.org/atlassian/jira-sketch-plugin.git" 29 | }, 30 | "keywords": [ 31 | "sketch", 32 | "jira" 33 | ], 34 | "author": "Tim Pettersen ", 35 | "license": "Apache-2.0", 36 | "homepage": "https://sketch.atlassian.com", 37 | "engines": { 38 | "sketch": ">=3.0" 39 | }, 40 | "devDependencies": { 41 | "ava": "^0.20.0", 42 | "babel-eslint": "^7.2.3", 43 | "babel-plugin-add-module-exports": "0.2.1", 44 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 45 | "babel-plugin-transform-react-jsx": "^6.8.0", 46 | "babel-preset-es2015": "^6.18.0", 47 | "babel-preset-react": "^6.16.0", 48 | "babel-preset-stage-1": "^6.24.1", 49 | "bitbucket-build-status": "^1.0.3", 50 | "chromedriver": "^2.31.0", 51 | "css-loader": "^0.28.3", 52 | "eslint": "^3.19.0", 53 | "eslint-config-sketch": "^0.2.0", 54 | "eslint-config-standard": "^10.2.0", 55 | "eslint-config-standard-react": "^5.0.0", 56 | "eslint-plugin-import": "^2.2.0", 57 | "eslint-plugin-node": "4.2.2", 58 | "eslint-plugin-promise": "^3.5.0", 59 | "eslint-plugin-react": "^6.10.3", 60 | "eslint-plugin-standard": "^3.0.1", 61 | "http-server": "^0.10.0", 62 | "raw-loader": "^0.5.1", 63 | "regexp-replace-loader": "^1.0.0", 64 | "selenium-webdriver": "^3.5.0", 65 | "skpm": "^0.9.13", 66 | "style-loader": "^0.18.1" 67 | }, 68 | "dependencies": { 69 | "@atlaskit/avatar": "^4.0.6", 70 | "@atlaskit/banner": "^2.4.2", 71 | "@atlaskit/button": "^1.1.4", 72 | "@atlaskit/button-group": "^1.1.3", 73 | "@atlaskit/css-reset": "^1.1.4", 74 | "@atlaskit/dropdown-menu": "^3.0.1", 75 | "@atlaskit/dynamic-table": "^3.0.0", 76 | "@atlaskit/field-base": "^7.4.2", 77 | "@atlaskit/field-text": "^3.4.2", 78 | "@atlaskit/icon": "^7.0.1", 79 | "@atlaskit/lozenge": "^3.4.2", 80 | "@atlaskit/media-card": "^17.9.0", 81 | "@atlaskit/media-core": "^8.2.0", 82 | "@atlaskit/mention": "^7.3.2", 83 | "@atlaskit/spinner": "^3.0.0", 84 | "@atlaskit/util-shared-styles": "^2.10.0", 85 | "atlassian-jwt": "^0.1.5", 86 | "babel-polyfill": "^6.23.0", 87 | "cocoascript-class-babel-safe": "^0.1.2", 88 | "filesize": "^3.5.10", 89 | "lodash": "^4.17.4", 90 | "mime-types": "^2.1.16", 91 | "mobx": "^3.2.1", 92 | "mobx-react": "^4.2.2", 93 | "moment": "^2.18.1", 94 | "promise-queue": "^2.2.3", 95 | "prop-types": "^15.5.10", 96 | "query-string": "^4.3.4", 97 | "react": "^15.5.4", 98 | "react-dom": "^15.5.4", 99 | "react-textarea-autosize": "^5.0.7", 100 | "rxjs": "^5.4.2", 101 | "sketch-polyfill-fetch-babel-safe": "^0.1.5", 102 | "sketch-module-fs": "^0.1.2", 103 | "sketch-module-update": "^0.1.2", 104 | "sketch-module-user-preferences": "^1.0.1", 105 | "sketch-module-web-view": "^0.1.4", 106 | "styled-components": "^1.3.0", 107 | "url-parse": "^1.1.9", 108 | "url-search-params": "^0.6.1", 109 | "uuid": "^3.1.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /s3_upload.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file 4 | # except in compliance with the License. A copy of the License is located at 5 | # 6 | # http://aws.amazon.com/apache2.0/ 7 | # 8 | # or in the "license" file accompanying this file. This file is distributed on an "AS IS" 9 | # BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations under the License. 11 | """ 12 | A BitBucket Builds template for deploying an application revision to AWS CodeDeploy 13 | narshiva@amazon.com 14 | v1.0.0 15 | """ 16 | from __future__ import print_function 17 | import os 18 | import sys 19 | import argparse 20 | import boto3 21 | from botocore.exceptions import ClientError 22 | 23 | def upload_to_s3(bucket, artefact, bucket_key): 24 | """ 25 | Uploads an artefact to Amazon S3 26 | """ 27 | try: 28 | client = boto3.client('s3') 29 | except ClientError as err: 30 | print("Failed to create boto3 client.\n" + str(err)) 31 | return False 32 | try: 33 | client.put_object( 34 | Body=open(artefact, 'rb'), 35 | Bucket=bucket, 36 | Key=bucket_key 37 | ) 38 | except ClientError as err: 39 | print("Failed to upload artefact to S3.\n" + str(err)) 40 | return False 41 | except IOError as err: 42 | print("Failed to access artefact in this directory.\n" + str(err)) 43 | return False 44 | return True 45 | 46 | 47 | def main(): 48 | 49 | parser = argparse.ArgumentParser() 50 | parser.add_argument("bucket", help="Name of the existing S3 bucket") 51 | parser.add_argument("artefact", help="Name of the artefact to be uploaded to S3") 52 | parser.add_argument("bucket_key", help="Name of the S3 Bucket key") 53 | args = parser.parse_args() 54 | 55 | if not upload_to_s3(args.bucket, args.artefact, args.bucket_key): 56 | sys.exit(1) 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/jira-cloud-for-sketch/04a63db44f21d51e34e530503ed64fc611540ae1/screenshots.png -------------------------------------------------------------------------------- /src/analytics.js: -------------------------------------------------------------------------------- 1 | import fetch from 'sketch-polyfill-fetch-babel-safe' 2 | import moment from 'moment' 3 | import { 4 | pluginVersion, 5 | analyticsApiSingleEvent, 6 | analyticsApiMultipleEvents, 7 | analyticsIdKey 8 | } from './config' 9 | import { isAuthorized, getJiraHost } from './auth' 10 | import { trace, isTraceEnabled, warn } from './logger' 11 | 12 | var analyticsId = NSUserDefaults.standardUserDefaults().objectForKey(analyticsIdKey) 13 | // an early bug meant we serialized the string 'null' and stored it 14 | if (!analyticsId || analyticsId == 'null') { 15 | analyticsId = NSUUID.UUID().UUIDString() + '' 16 | NSUserDefaults.standardUserDefaults().setObject_forKey( 17 | analyticsId, 18 | analyticsIdKey 19 | ) 20 | } 21 | // convert to js string 22 | analyticsId = analyticsId + '' 23 | 24 | /** 25 | * Create an analytics event payload. 26 | * 27 | * @param {string} event the event name 28 | * @param {Object} properties additional properties to send with the event (see 29 | * https://extranet.atlassian.com/display/MOD/Public+Analytics+aka+GAS for 30 | * restrictions regarding these properties). Do NOT send user content or 31 | * personally identifying information in events. 32 | */ 33 | function event (eventName, properties) { 34 | // https://extranet.atlassian.com/display/MOD/Public+Analytics+aka+GAS 35 | const payload = { 36 | name: eventName, 37 | server: isAuthorized() ? getJiraHost() : '-', 38 | product: 'atlassian-sketch-plugin', 39 | subproduct: 'jira', 40 | version: pluginVersion, 41 | user: analyticsId, 42 | serverTime: moment.now() 43 | } 44 | if (properties) { 45 | payload.properties = properties 46 | } 47 | return payload 48 | } 49 | 50 | /** 51 | * Send a single analytics event. 52 | */ 53 | export async function analytics (eventName, properties) { 54 | return postToAnalyticsApi( 55 | analyticsApiSingleEvent, 56 | event(eventName, properties) 57 | ) 58 | } 59 | 60 | /** 61 | * Send an array of analytics events. 62 | */ 63 | export async function analyticsBatch (events) { 64 | return postToAnalyticsApi(analyticsApiMultipleEvents, { 65 | events: events.map(({name, properties}) => event(name, properties)) 66 | }) 67 | } 68 | 69 | async function postToAnalyticsApi (api, payload) { 70 | const body = JSON.stringify(payload) 71 | trace(`analytics event(s) ${body}`) 72 | try { 73 | const res = await fetch(api, { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json' 77 | }, 78 | body: body 79 | }) 80 | if (isTraceEnabled()) { 81 | trace(`analytics posted (${res.status})`) 82 | trace(await res.text()) 83 | } 84 | } catch (e) { 85 | warn(`Failed to send analytics event(s) ${e}`) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/blank-thumbnail-datauri.txt: -------------------------------------------------------------------------------- 1 | data:image/png;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjgwcHgiIGhlaWdodD0iODBweCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy41LjEgKDI1MjM0KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT4xNi9pMDA0Nl9kZXZ0b29scy1maWxlPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IkNyb3NzLXByb2R1Y3QtaWNvbnMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSIxNi9pMDA0Nl9kZXZ0b29scy1maWxlIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjZTllOWU5Ij4KICAgICAgICAgICAgPHBhdGggZD0iTTUsMCBMMTIsMCBDMTIuODI4MTI5MSwwIDEzLjUzNTE1MzMsMC4yOTI5NjU4MiAxNC4xMjEwOTM4LDAuODc4OTA2MjUgQzE0LjcwNzAzNDIsMS40NjQ4NDY2OCAxNSwyLjE3MTg3MDg2IDE1LDMgTDE1LDEzIEMxNSwxMy44MjgxMjkxIDE0LjcwNzAzNDIsMTQuNTM1MTUzMyAxNC4xMjEwOTM4LDE1LjEyMTA5MzggQzEzLjUzNTE1MzMsMTUuNzA3MDM0MiAxMi44MjgxMjkxLDE2IDEyLDE2IEw1LDE2IEM0LjE3MTg3MDg2LDE2IDMuNDY0ODQ2NjgsMTUuNzA3MDM0MiAyLjg3ODkwNjI1LDE1LjEyMTA5MzggQzIuMjkyOTY1ODIsMTQuNTM1MTUzMyAyLDEzLjgyODEyOTEgMiwxMyBMMiwzIEMyLDIuMTcxODcwODYgMi4yOTI5NjU4MiwxLjQ2NDg0NjY4IDIuODc4OTA2MjUsMC44Nzg5MDYyNSBDMy40NjQ4NDY2OCwwLjI5Mjk2NTgyIDQuMTcxODcwODYsMCA1LDAgWiBNMTQsMTMgTDE0LDMgQzE0LDIuNDQ3OTEzOTEgMTMuODAzMzg3NCwxLjk3NjU2NDQ1IDEzLjQxMDE1NjIsMS41ODU5Mzc1IEMxMy4wMTY5MjUxLDEuMTk1MzEwNTUgMTIuNTQ2ODc3NywxIDEyLDEgTDUsMSBDNC40NTMxMjIyNywxIDMuOTgzMDc0ODgsMS4xOTUzMTA1NSAzLjU4OTg0Mzc1LDEuNTg1OTM3NSBDMy4xOTY2MTI2MiwxLjk3NjU2NDQ1IDMsMi40NDc5MTM5MSAzLDMgTDMsMTMgQzMsMTMuNTUyMDg2MSAzLjE5NjYxMjYyLDE0LjAyMzQzNTUgMy41ODk4NDM3NSwxNC40MTQwNjI1IEMzLjk4MzA3NDg4LDE0LjgwNDY4OTUgNC40NTMxMjIyNywxNSA1LDE1IEwxMiwxNSBDMTIuNTQ2ODc3NywxNSAxMy4wMTY5MjUxLDE0LjgwNDY4OTUgMTMuNDEwMTU2MiwxNC40MTQwNjI1IEMxMy44MDMzODc0LDE0LjAyMzQzNTUgMTQsMTMuNTUyMDg2MSAxNCwxMyBaIE0xMiw3IEw1LDcgTDUsNiBMMTIsNiBMMTIsNyBaIE0xMiw5IEw1LDkgTDUsOCBMMTIsOCBMMTIsOSBaIE0xMiwxMSBMNSwxMSBMNSwxMCBMMTIsMTAgTDEyLDExIFogTTEyLDEzIEw1LDEzIEw1LDEyIEwxMiwxMiBMMTIsMTMgWiIgaWQ9ImkwMDQ2X2RldnRvb2xzLWZpbGUtMTYiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg== -------------------------------------------------------------------------------- /src/commands/README.md: -------------------------------------------------------------------------------- 1 | # /commands 2 | 3 | Commands that are listed in manifest.json and invoked by Sketch. All entry 4 | points to runtime code are contained in this package. 5 | -------------------------------------------------------------------------------- /src/commands/jira.js: -------------------------------------------------------------------------------- 1 | import '../default-imports' 2 | import launchPanel from '../views/panels/launch' 3 | import { analytics } from '../analytics' 4 | 5 | /** 6 | * The primary command. Launches the authorization panel if the plugin isn't 7 | * currently connected to a Jira Cloud site, or the Jira panel if it is. 8 | * 9 | * @param {Object} context provided by Sketch 10 | */ 11 | export default function (context) { 12 | COScript.currentCOScript().setShouldKeepAround(true) 13 | launchPanel(context) 14 | analytics('openPanelByMenuOrShortcut') 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/on-selection-or-export-change.js: -------------------------------------------------------------------------------- 1 | import '../default-imports' 2 | import { executeSafelyAsync } from '../util' 3 | import exportButton from '../views/controls/export-button' 4 | 5 | /** 6 | * Adds or removes the export button to Sketch's export panel, based on whether 7 | * an issue is currently selected. This should be invoked any time the export 8 | * dialog may appear or disappear, e.g. when the selected layer changes, or a 9 | * new export option is added. 10 | * 11 | * @param {Object} context provided by Sketch 12 | */ 13 | export default async function (context) { 14 | COScript.currentCOScript().setShouldKeepAround(true) 15 | executeSafelyAsync(context, function () { 16 | exportButton.add(context) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/commands/startup.js: -------------------------------------------------------------------------------- 1 | import '../default-imports' 2 | import { trace, error } from '../logger' 3 | import { isAuthorized, getBearerToken } from '../auth' 4 | import { bearerTokenRefreshInterval } from '../config' 5 | import { analytics } from '../analytics' 6 | 7 | /** 8 | * Triggered on plugin startup (when the plugin is installed and on subsequent 9 | * Sketch restarts). Periodically warms the user's auth token token cache. 10 | */ 11 | export default function () { 12 | trace('Registering auth token cache freshener...') 13 | COScript.currentCOScript().setShouldKeepAround(true) 14 | refreshAuthToken() 15 | setInterval(refreshAuthToken, bearerTokenRefreshInterval) 16 | analytics('pluginStart') 17 | } 18 | 19 | function refreshAuthToken () { 20 | if (isAuthorized()) { 21 | getBearerToken(true) 22 | .then(function () { 23 | trace('Auth token refreshed!') 24 | }) 25 | .catch(function (e) { 26 | error(e) 27 | }) 28 | } else { 29 | trace('Not authorized. Skipping auth token refresh.') 30 | } 31 | analytics('refreshAuthToken') 32 | } 33 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Plugin configuration & constants. Some properties are hardcoded, but most 3 | * are user hackable via a `config.json` file in the plugin's `Resource` 4 | * directory. Defaults are also defined in this file in case the user deletes 5 | * a property from `config.json`. If you add a new property to `config.json`, make 6 | * sure you add it here too! 7 | */ 8 | 9 | import { assign, isNumber } from 'lodash' 10 | import { resourcesPath, scriptsPath, readFileAsJson } from './util' 11 | 12 | const defaults = { 13 | logLevel: 10, 14 | jiraSketchIntegrationBaseUrl: 'https://sketch.atlassian.com', 15 | analyticsApiBaseUrl: 'https://mgas.prod.public.atl-paas.net/v1', 16 | bearerTokenExpirySafetyMargin: 60, // seconds 17 | bearerTokenRefreshInterval: 1000 * 60 * 10, // milliseconds 18 | thumbnailDownloadConcurrency: 4, 19 | attachmentUploadConcurrency: 4, 20 | thumbnailRetryMax: 25, 21 | thumbnailRetryDelay: 600, // milliseconds 22 | userAuthorizationPollInterval: 2000, // milliseconds 23 | cocoaDelegatePollInterval: 200, // milliseconds 24 | jiraAuthorizationUrlMaxRetries: 3, 25 | jiraAuthorizationUrlRetryInterval: 3000, // milliseconds 26 | maxMentionPickerResults: 20 27 | } 28 | const configFile = readFileAsJson(`${resourcesPath()}/config.json`) 29 | const config = assign({}, defaults, configFile) 30 | 31 | console.log(JSON.stringify(config)) 32 | 33 | const manifest = readFileAsJson(`${scriptsPath()}/manifest.json`) 34 | 35 | export const logLevels = { 36 | TRACE: 10, 37 | DEBUG: 20, 38 | INFO: 30, 39 | WARN: 40, 40 | ERROR: 50 41 | } 42 | 43 | export const logLevel = parseLogLevel(config.logLevel) 44 | 45 | export const pluginName = 'jira-sketch-plugin' 46 | export const tempDirName = pluginName 47 | 48 | export const pluginVersion = manifest.version 49 | 50 | export const jiraSketchIntegrationBaseUrl = config.jiraSketchIntegrationBaseUrl 51 | 52 | export const jiraSketchIntegrationApiBaseUrl = `${jiraSketchIntegrationBaseUrl}/api` 53 | 54 | export const jiraSketchIntegrationApi = { 55 | authorize: `${jiraSketchIntegrationApiBaseUrl}/authorize`, 56 | client: `${jiraSketchIntegrationApiBaseUrl}/clients`, 57 | bearer: `${jiraSketchIntegrationApiBaseUrl}/clients/bearer` 58 | } 59 | 60 | export const jiraSketchIntegrationFaqUrl = `${jiraSketchIntegrationBaseUrl}/faq` 61 | 62 | export const standardIssueFields = [ 63 | 'issuetype', 64 | 'summary', 65 | 'status', 66 | 'attachment', 67 | 'assignee', 68 | 'reporter' 69 | ] 70 | 71 | export const analyticsApiBaseUrl = config.analyticsApiBaseUrl 72 | export const analyticsApiSingleEvent = `${analyticsApiBaseUrl}/event` 73 | export const analyticsApiMultipleEvents = `${analyticsApiBaseUrl}/events` 74 | export const analyticsIdKey = 'atlassian.analytics.id' 75 | 76 | export const bearerTokenExpirySafetyMargin = config.bearerTokenExpirySafetyMargin 77 | export const bearerTokenRefreshInterval = config.bearerTokenRefreshInterval 78 | 79 | export const thumbnailDownloadConcurrency = config.thumbnailDownloadConcurrency 80 | export const attachmentUploadConcurrency = config.attachmentUploadConcurrency 81 | 82 | export const thumbnailRetryMax = config.thumbnailRetryMax 83 | export const thumbnailRetryDelay = config.thumbnailRetryDelay 84 | 85 | export const userAuthorizationPollInterval = config.userAuthorizationPollInterval 86 | 87 | export const jiraAuthorizationUrlMaxRetries = config.jiraAuthorizationUrlMaxRetries 88 | export const jiraAuthorizationUrlRetryInterval = config.jiraAuthorizationUrlRetryInterval 89 | 90 | export const feedbackUrl = 'https://goo.gl/forms/OrIB4RoEePhL3lkv2' 91 | 92 | export const cocoaDelegatePollInterval = config.cocoaDelegatePollInterval 93 | 94 | export const jiraDateMomentFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZZ' 95 | 96 | export const maxMentionPickerResults = config.maxMentionPickerResults 97 | 98 | function parseLogLevel (level) { 99 | if (isNumber(level)) { 100 | return level 101 | } 102 | const levelName = level.trim().toUpperCase() 103 | if (logLevels[levelName]) { 104 | return logLevels[levelName] 105 | } 106 | const levelValue = parseInt(level) 107 | if (levelValue) { 108 | return levelValue 109 | } 110 | console.log(`Couldn't parse log level: '${level}'`) 111 | return defaults.logLevel 112 | } 113 | -------------------------------------------------------------------------------- /src/default-imports.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Scripts that must be included *first* in every entry point, e.g. all Sketch 3 | * commands and action handlers. 4 | */ 5 | 6 | import 'babel-polyfill' 7 | import './frameworks/AtlassianSketchFramework' 8 | import upgradeIfNeeded from './upgrade/upgrade' 9 | 10 | upgradeIfNeeded() 11 | -------------------------------------------------------------------------------- /src/entity-mappers.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Functions for mapping REST entities returned from the Jira API to lighter 3 | * weight internal representations from inside the plugin. Since these entities 4 | * are often logged, serialized, and pass over the CocoaScript-JavaScript 5 | * (which has memory constraints), it's good practice to keep them as slim as 6 | * possible. 7 | */ 8 | 9 | import { assign, pick, sortBy } from 'lodash' 10 | 11 | const typeProperties = [ 12 | 'name', 13 | 'iconUrl' 14 | ] 15 | const attachmentProperties = [ 16 | 'id', 17 | 'filename', 18 | 'created', 19 | 'size', 20 | 'mimeType', 21 | 'content', 22 | 'thumbnail' 23 | ] 24 | 25 | /** 26 | * @param {Object} issue a JSON issue from the Jira REST API (see 27 | * https://docs.atlassian.com/jira/REST/cloud/#api/2/issue-getIssue) 28 | * @return {Object} a simpler representation of the issue. Notably: important 29 | * fields are promoted from the 'fields' property to the root, and the 30 | * attachments are sorted by reverse creation order. 31 | */ 32 | export function issueFromRest (issue) { 33 | const { issuetype, attachment, summary, assignee, reporter, status } = issue.fields 34 | // we always display attachments by created date 35 | const attachments = attachment ? sortBy(attachment, 'created').reverse() : [] 36 | return assign( 37 | pick(issue, 'key', 'self'), 38 | { 39 | summary, 40 | type: issuetype ? pick(issuetype, typeProperties) : null, 41 | attachments, 42 | assignee, 43 | reporter, 44 | status 45 | } 46 | ) 47 | } 48 | 49 | /** 50 | * @param {Object} attachment a JSON attachment from the Jira REST API. (see 51 | * https://docs.atlassian.com/jira/REST/cloud/#api/2/attachment-getAttachment) 52 | * @return {Object} a representation of the attachment with a restricted set of 53 | * properties. 54 | */ 55 | export function attachmentFromRest (attachment) { 56 | return pick(attachment, attachmentProperties) 57 | } 58 | -------------------------------------------------------------------------------- /src/error/AuthorizationError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Indicates there is a problem with the user's authorization status with Jira, 3 | * e.g. the user has revoked the plugin's authorization via their Jira profile 4 | * page. If thrown, the plugin UI should allow the user to reauthorize with 5 | * Jira. 6 | */ 7 | export default class AuthorizationError { 8 | constructor (message) { 9 | this.name = 'AuthorizationError' 10 | this.message = message 11 | this.stack = new Error().stack 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/error/FaqError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Indicates an expected error case has occurred. If thrown, the UI should 3 | * display the supplied message and link to the relevant FAQ page. 4 | */ 5 | export default class FaqError { 6 | /** 7 | * @param {string} message a descriptive message to be displayed to the user 8 | * @param {string} faqTopic a relevant FAQ topic the user can browse to 9 | */ 10 | constructor (message, faqTopic) { 11 | this.name = 'FaqError' 12 | this.faqTopic = faqTopic 13 | this.message = message 14 | this.stack = new Error().stack 15 | } 16 | } 17 | 18 | /** 19 | * Property values correspond to anchors at https://sketch.atlassian.com/faq 20 | */ 21 | export const faqTopics = { 22 | CAN_NOT_CONNECT: 'can-not-connect', 23 | CONTACT_SUPPORT: 'contact-support', 24 | NO_PERMISSION: 'no-permission', 25 | FILE_TOO_LARGE: 'limits' 26 | } 27 | -------------------------------------------------------------------------------- /src/error/README.md: -------------------------------------------------------------------------------- 1 | # /error 2 | 3 | Common error classes. 4 | -------------------------------------------------------------------------------- /src/export.js: -------------------------------------------------------------------------------- 1 | import { tempDir, localPathToNSURLString, documentFromContext } from './util' 2 | import { analytics } from './analytics' 3 | import { trace, error } from './logger' 4 | import { layerLastExportedIssue, documentLastViewedIssue } from './properties' 5 | 6 | const badFilenameChars = new RegExp('/', 'g') 7 | 8 | /** 9 | * Exports the currently selected layers to a temp directory, using the layers' 10 | * configured export settings. 11 | * 12 | * @param {Object} context provided by Sketch 13 | * @param {string} issueKey identifies the issue the layers will be exported to 14 | * @return {string[]} the exported paths 15 | */ 16 | export function exportSelection (context, issueKey) { 17 | const dir = tempDir(`export-${Date.now()}`) 18 | const exportedPaths = [] 19 | let layerCount = 0 20 | withDocument(context, document => { 21 | forEachSelectedLayer(context, layer => { 22 | exportedPaths.push(...exportLayer(document, layer, dir)) 23 | setLastExportedIssueForLayer(context, layer, issueKey) 24 | layerCount++ 25 | }) 26 | }) 27 | trace(`Exporting ${exportedPaths.length} assets to ${dir}`) 28 | if (layerCount > 1) { 29 | analytics('exportSelectedLayers', { count: layerCount }) 30 | } else { 31 | analytics('exportSelectedLayer') 32 | } 33 | return exportedPaths 34 | } 35 | 36 | /** 37 | * Export a layer to a particular directory using the filename suggested by 38 | * Sketch. 39 | * 40 | * @param {Object} document the selected document from the current Sketch context 41 | * @param {Object} layer a Sketch layer 42 | * @param {string} dir the directory to export to 43 | * @return {string[]} the exported paths for each slice in this layer 44 | */ 45 | function exportLayer (document, layer, dir) { 46 | const exportedPaths = [] 47 | const slices = MSExportRequest.exportRequestsFromExportableLayer(layer) 48 | for (let i = 0; i < slices.count(); i++) { 49 | const slice = slices[i] 50 | const filepath = dir + nameForSlice(slice) 51 | document.saveArtboardOrSlice_toFile(slice, filepath) 52 | exportedPaths.push(localPathToNSURLString(filepath)) 53 | } 54 | if (exportedPaths.length > 1) { 55 | analytics('exportMultipleFormats', {count: exportedPaths.length}) 56 | } else { 57 | analytics('exportSingleFormat') 58 | } 59 | return exportedPaths 60 | } 61 | 62 | /** 63 | * @param {Object} slice a Sketch export request (generated by MSExportRequest) 64 | * @return {string} a suitable filename for the slice 65 | */ 66 | function nameForSlice (slice) { 67 | return `${encodeLayerNameAsFilename(slice.name())}.${slice.format()}` 68 | } 69 | 70 | /** 71 | * @param {string} layerName the layer's name 72 | * @return {string} a new filesystem-safe layer name 73 | */ 74 | function encodeLayerNameAsFilename (layerName) { 75 | return layerName.replace(badFilenameChars, '_') 76 | } 77 | 78 | /** 79 | * @param {Object} context provided by Sketch 80 | * @param {Object} layer a Sketch layer 81 | * @param {string} issueKey identifies the issue the layers was or will be 82 | * exported to 83 | */ 84 | function setLastExportedIssueForLayer (context, layer, issueKey) { 85 | context.command.setValue_forKey_onLayer(issueKey, layerLastExportedIssue, layer) 86 | } 87 | 88 | /** 89 | * @param {Object} context provided by Sketch 90 | * @return {string} the last exported to issue from one of the layers in the 91 | * user's current selection. If there are multiple last exported issues, the 92 | * one from the first layer in the user's selection is returned 93 | */ 94 | export function getLastExportedIssueForSelectedLayers (context) { 95 | let issueKey = null 96 | forEachSelectedLayer(context, layer => { 97 | issueKey = issueKey || context.command.valueForKey_onLayer(layerLastExportedIssue, layer) 98 | }) 99 | return issueKey ? issueKey + '' : null 100 | } 101 | 102 | /** 103 | * @param {Object} context provided by Sketch 104 | * @param {string} issueKey identifies the issue that was last exported to for 105 | * the current document 106 | */ 107 | export function setLastViewedIssueForDocument (context, issueKey) { 108 | withDocument(context, document => { 109 | context.command.setValue_forKey_onDocument( 110 | issueKey, 111 | documentLastViewedIssue, 112 | document.documentData() 113 | ) 114 | }) 115 | } 116 | 117 | /** 118 | * @param {Object} context provided by Sketch 119 | * @return {string} the last viewed to issue for the current document 120 | */ 121 | export function getLastViewedIssueForDocument (context) { 122 | return withDocument(context, document => { 123 | const key = context.command.valueForKey_onDocument( 124 | documentLastViewedIssue, 125 | document.documentData() 126 | ) 127 | return key ? key + '' : null 128 | }) 129 | } 130 | 131 | /** 132 | * @param {Object} context provided by Sketch 133 | * @param {function} fn invoked with the current document 134 | * @return {*} the result of fn, or `null` if the document could not be resolved 135 | */ 136 | function withDocument (context, fn) { 137 | const document = documentFromContext(context) 138 | if (!document) { 139 | error('Couldn\'t resolve document from context') 140 | return 141 | } 142 | return fn(document) 143 | } 144 | 145 | /** 146 | * @param {Object} context provided by Sketch 147 | * @param {function} fn invoked with each layer in the user's current selection 148 | */ 149 | function forEachSelectedLayer (context, fn) { 150 | withDocument(context, document => { 151 | const selectedLayers = document.selectedLayers().layers() 152 | for (let i = 0; i < selectedLayers.count(); i++) { 153 | fn(selectedLayers[i]) 154 | } 155 | }) 156 | } 157 | 158 | /** 159 | * @param {Object} context provided by Sketch 160 | * @return {boolean} whether at least one layer is selected 161 | */ 162 | export function areLayersSelected (context) { 163 | let isSelected = false 164 | forEachSelectedLayer(context, () => { isSelected = true }) 165 | return isSelected 166 | } 167 | -------------------------------------------------------------------------------- /src/frameworks/AtlassianSketchFramework.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Loads the bundled AtlassianSketchFramework, if not already loaded. 3 | */ 4 | import { trace } from '../logger' 5 | import { resourcesPath } from '../util' 6 | 7 | var mocha = Mocha.sharedRuntime() 8 | var frameworkName = 'AtlassianSketchFramework' 9 | if (mocha.valueForKey(frameworkName)) { 10 | trace(`${frameworkName} already loaded.`) 11 | } else if ( 12 | mocha.loadFrameworkWithName_inDirectory(frameworkName, resourcesPath()) 13 | ) { 14 | trace(`${frameworkName} loaded!`) 15 | mocha.setValue_forKey_(true, frameworkName) 16 | } else { 17 | trace(`${frameworkName} failed to load!`) 18 | } 19 | -------------------------------------------------------------------------------- /src/frameworks/README.md: -------------------------------------------------------------------------------- 1 | # /frameworks 2 | 3 | Scripts for importing bundled Objective-C frameworks. 4 | -------------------------------------------------------------------------------- /src/jql-filters.js: -------------------------------------------------------------------------------- 1 | /* 2 | * The JQL search strings that power the Filters dropdown. 3 | */ 4 | 5 | import { forOwn, assign } from 'lodash' 6 | 7 | const jqlFilters = { 8 | 'RecentlyViewed': { 9 | displayName: 'Recently viewed', 10 | jql: 'issue in issueHistory() ' + 11 | 'order by lastViewed' 12 | }, 13 | 'AssignedToMe': { 14 | displayName: 'Assigned to me', 15 | jql: 'assignee = currentUser() ' + 16 | 'and resolution = Unresolved ' + 17 | 'order by lastViewed' 18 | }, 19 | 'MentioningMe': { 20 | displayName: '@mentioning me', 21 | jql: 'text ~ currentUser() ' + 22 | 'order by lastViewed' 23 | } 24 | } 25 | 26 | const filterArray = [] 27 | forOwn(jqlFilters, (filter, key) => { 28 | filterArray.push(assign({key}, filter)) 29 | }) 30 | 31 | export default jqlFilters 32 | export const jqlFilterArray = filterArray 33 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | import { logLevels, logLevel } from './config' 2 | 3 | export function trace (message) { 4 | isTraceEnabled() && log(message) 5 | return message 6 | } 7 | 8 | export function isTraceEnabled () { 9 | return logLevel <= logLevels.TRACE 10 | } 11 | 12 | export function debug (message) { 13 | isDebugEnabled() && log(message) 14 | return message 15 | } 16 | 17 | export function isDebugEnabled () { 18 | return logLevel <= logLevels.DEBUG 19 | } 20 | 21 | export function info (message) { 22 | isInfoEnabled() && log(message) 23 | return message 24 | } 25 | 26 | export function isInfoEnabled () { 27 | return logLevel <= logLevels.INFO 28 | } 29 | 30 | export function warn (message) { 31 | isWarnEnabled() && log(message) 32 | return message 33 | } 34 | 35 | export function isWarnEnabled () { 36 | return logLevel <= logLevels.WARN 37 | } 38 | 39 | export function error (message) { 40 | isErrorEnabled() && log(message) 41 | return message 42 | } 43 | 44 | export function isErrorEnabled () { 45 | return logLevel <= logLevels.ERROR 46 | } 47 | 48 | function log (message) { 49 | if (typeof message == 'function') { 50 | message = message() 51 | } 52 | console.log(message) 53 | } 54 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jira Cloud for Sketch", 3 | "author": "Atlassian", 4 | "email": "tim@atlassian.com", 5 | "identifier": "com.atlassian.sketch.jira-plugin", 6 | "appcast": "https://s3-us-west-2.amazonaws.com/atlassian-sketch-plugin/appcast.xml", 7 | "compatibleVersion": 3, 8 | "bundleVersion": 1, 9 | "commands": [ 10 | { 11 | "name": "Jira", 12 | "identifier": "jira", 13 | "script": "./commands/jira.js", 14 | "shortcut": "cmd alt j", 15 | "description": "Open the 'Export to Jira Cloud' panel.", 16 | "icon": "jira-icon-runner.png" 17 | }, 18 | { 19 | "name": "StartUp", 20 | "identifier": "startup", 21 | "script": "./commands/startup.js", 22 | "handlers": { 23 | "actions": { 24 | "Startup": "onRun" 25 | } 26 | } 27 | }, 28 | { 29 | "name": "On Selection or Export Change", 30 | "identifier": "on-selection-or-export-change", 31 | "script": "./commands/on-selection-or-export-change.js", 32 | "handlers": { 33 | "actions": { 34 | "AddExportFormat": "onRun", 35 | "SelectionChanged": "onRun" 36 | } 37 | } 38 | } 39 | ], 40 | "menu": { 41 | "isRoot": true, 42 | "items": [ 43 | "jira" 44 | ] 45 | } 46 | } -------------------------------------------------------------------------------- /src/pasteboard.js: -------------------------------------------------------------------------------- 1 | import { trace } from './logger' 2 | 3 | /** 4 | * Get paths for exported layers or other files that the user has just dragged. 5 | * 6 | * @return {string[]} an array of file urls from the system drag pasteboard. 7 | */ 8 | export function getDraggedFiles () { 9 | var pboard = NSPasteboard.pasteboardWithName(NSDragPboard) 10 | var items = pboard.pasteboardItems() 11 | var files = [] 12 | for (var i = 0; i < items.count(); i++) { 13 | var item = items[i] 14 | // accept any dragged type that declares a file-url 15 | var fileUrl = item.stringForType('public.file-url') 16 | if (fileUrl) { 17 | files.push(fileUrl) 18 | } 19 | } 20 | trace(`File urls from pasteboard: ["${files.join('", "')}"]`) 21 | return files 22 | } 23 | -------------------------------------------------------------------------------- /src/plugin-state.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Utilities for sharing state across plugin command and action invocations. 3 | * 4 | * TODO: introduce a cross-context event system? 5 | */ 6 | 7 | const keys = { 8 | selectedIssueKey: 'selectedIssueKey' 9 | } 10 | 11 | const dictionaryKey = 'jira-sketch-plugin-state' 12 | 13 | /** 14 | * A shared object that is shared across CocoaScript contexts by storing to and 15 | * retrieving from the main thread's thread dictionary. 16 | */ 17 | const pluginState = (function () { 18 | let _state = NSThread.mainThread().threadDictionary()[dictionaryKey] 19 | if (!_state) { 20 | _state = NSMutableDictionary.alloc().init() 21 | NSThread.mainThread().threadDictionary()[dictionaryKey] = _state 22 | } 23 | return _state 24 | })() 25 | 26 | /** 27 | * Determine if the Jira panel is open and an issue is selected. 28 | * 29 | * @return {string} the currently selected issue, or null if no issue is selected 30 | */ 31 | export function getSelectedIssueKey () { 32 | const issueKey = pluginState[keys.selectedIssueKey] 33 | return issueKey ? issueKey + '' : null // convert to JS string 34 | } 35 | 36 | /** 37 | * Set the currently selected issue. 38 | * 39 | * @param {string} key the currently selected issue 40 | */ 41 | export function setSelectedIssueKey (key) { 42 | pluginState[keys.selectedIssueKey] = key 43 | } 44 | -------------------------------------------------------------------------------- /src/prefs.js: -------------------------------------------------------------------------------- 1 | import prefsManager from 'sketch-module-user-preferences' 2 | import { mapValues } from 'lodash' 3 | import { pluginName } from './config' 4 | 5 | const _NOT_SET = '__NOT_SET' 6 | 7 | /** 8 | * Preference keys. All stored preferences *must* be keyed by one of these 9 | * keys. 10 | */ 11 | export const keys = { 12 | // client id and sharedSecret (generated by addon server) 13 | clientId: 'clientId', 14 | sharedSecret: 'sharedSecret', 15 | 16 | // location of addon server API (usually prod) 17 | addonUrl: 'addonUrl', 18 | 19 | // location of Jira Cloud site that we're currently connected to 20 | jiraHost: 'jiraHost', 21 | 22 | // indicates whether we're authorized for the current Jira host 23 | // (set if auth token has been successfully retrieved at least once) 24 | authorized: 'authorized', 25 | 26 | // cached auth token 27 | authToken: 'authToken', 28 | authTokenExpiry: 'authTokenExpiry', 29 | 30 | // the index of the next upgrade task to run. If an upgrade task 31 | // with this index doesn't exist, then we're up to date. 32 | nextUpgradeIndex: 'nextUpgradeIndex' 33 | } 34 | 35 | /** 36 | * @return {object} the currently stored preferences 37 | */ 38 | function getUserPrefs () { 39 | return prefsManager.getUserPreferences( 40 | pluginName, 41 | mapValues(keys, () => _NOT_SET) 42 | ) 43 | } 44 | 45 | /** 46 | * @param {object} prefs the new preferences object to store 47 | */ 48 | function setUserPrefs (prefs) { 49 | prefsManager.setUserPreferences(pluginName, prefs) 50 | } 51 | 52 | /** 53 | * @param {object} value a preference value 54 | * @return {boolean} true if a value appears to be set 55 | */ 56 | function isValueSet (value) { 57 | return value && value !== _NOT_SET && value !== 'null' 58 | } 59 | 60 | /** 61 | * @param {string} key a preference key 62 | * @return {string} the preference value 63 | * @throws if no value is set for the specified key 64 | */ 65 | export function getString (key) { 66 | const prefs = getUserPrefs() 67 | const value = prefs[key] 68 | if (isValueSet(value)) { 69 | return value + '' 70 | } 71 | throw new Error(`No preference set for key "${key}"`) 72 | } 73 | 74 | /** 75 | * @param {string} key a preference key 76 | * @return {number} the preference value 77 | * @throws if no value is set for the specified key 78 | */ 79 | 80 | export function getInt (key) { 81 | return parseInt(getString(key)) 82 | } 83 | 84 | /** 85 | * @param {string} key a preference key 86 | * @param {object} value a preference value (will be converted to a string) 87 | */ 88 | export function setString (key, value) { 89 | var prefs = getUserPrefs() 90 | prefs[key] = value + '' 91 | setUserPrefs(prefs) 92 | } 93 | 94 | /** 95 | * @param {...string} keys preference keys 96 | * @return {boolean} true iff all the keys are set 97 | */ 98 | export function isSet (/* key, keys... */) { 99 | var prefs = getUserPrefs() 100 | for (var i = 0; i < arguments.length; i++) { 101 | var key = arguments[i] 102 | var value = prefs[key] 103 | if (!isValueSet(value)) { 104 | return false 105 | } 106 | } 107 | return true 108 | } 109 | 110 | /** 111 | * Unset the values for an array of preference keys 112 | * 113 | * @param {...string} keys preference keys 114 | */ 115 | export function unset (/* [key, keys...] */) { 116 | var args = Array.from(arguments) 117 | var prefs = getUserPrefs() 118 | prefs = mapValues(prefs, (value, key) => { 119 | if (args.indexOf(key) > -1) { 120 | return null 121 | } else { 122 | return value 123 | } 124 | }) 125 | setUserPrefs(prefs) 126 | } 127 | -------------------------------------------------------------------------------- /src/properties.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Property keys. Properties are key-value pairs stored against Sketch domain 3 | * objects (layers, documents, etc.) 4 | */ 5 | 6 | /** 7 | * The last issue a particular layer was exported to. 8 | */ 9 | export const layerLastExportedIssue = 'layer.last.exported.issue' 10 | 11 | /** 12 | * The last issue viewed in the Jira panel whilst a particular Sketch document 13 | * was active. 14 | */ 15 | export const documentLastViewedIssue = 'document.last.viewed.issue' 16 | -------------------------------------------------------------------------------- /src/token-cache.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import { isSet, getInt, getString, setString, unset, keys } from './prefs' 3 | import { trace } from './logger' 4 | import { bearerTokenExpirySafetyMargin } from './config' 5 | 6 | /** 7 | * Retrieves and caches auth tokens, to avoid making the user wait for an auth 8 | * token each time they start interacting with the plugin. 9 | * 10 | * Note: Add-ons are allowed 500 access token requests every 5 minutes for each 11 | * host product the add-on is installed on. Eagerly fetching tokens may become 12 | * problematic for companies with one Jira Cloud site and >500 concurrent Sketch 13 | * users. 14 | * 15 | * @see https://developer.atlassian.com/cloud/jira/software/oauth-2-jwt-bearer-token-authorization-grant-type/#rate-limiting 16 | */ 17 | export default class TokenCache { 18 | /** 19 | * @param {function} getNewToken a reentrant function that can be used to 20 | * retrieve a new auth token 21 | */ 22 | constructor (getNewToken) { 23 | this.getNewToken = getNewToken 24 | } 25 | /** 26 | * @param {boolean} force skip & ovewrite the cache 27 | * @return {string} an auth token 28 | */ 29 | async get (force) { 30 | const now = moment.utc().unix() 31 | if (force) { 32 | trace('bearerTokenForceRefresh') 33 | } else { 34 | if (isSet(keys.authToken, keys.authTokenExpiry)) { 35 | const token = getString(keys.authToken) 36 | const expiry = getInt(keys.authTokenExpiry) 37 | if (expiry > now) { 38 | trace('bearerTokenCacheHit') 39 | return token 40 | } 41 | } 42 | trace('bearerTokenCacheMiss') 43 | } 44 | const newToken = await this.getNewToken() 45 | setString(keys.authToken, newToken[0]) 46 | setString(keys.authTokenExpiry, now + newToken[1] - bearerTokenExpirySafetyMargin) 47 | return newToken[0] 48 | } 49 | /** 50 | * Clear the token cache 51 | */ 52 | flush () { 53 | unset(keys.authToken, keys.authTokenExpiry) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/upgrade/000_addon_url_to_sketch_atlassian_com.js: -------------------------------------------------------------------------------- 1 | import { keys, setString, getString, isSet } from '../prefs' 2 | 3 | const oldBaseUrl = 'https://sketch.prod.atl-paas.net' 4 | const newBaseUrl = 'https://sketch.atlassian.com' 5 | 6 | /** 7 | * Replaces the plugin beta's base URL with the new production base URL. 8 | */ 9 | export default function () { 10 | if (isSet(keys.addonUrl) && getString(keys.addonUrl) == oldBaseUrl) { 11 | setString(keys.addonUrl, newBaseUrl) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/upgrade/README.md: -------------------------------------------------------------------------------- 1 | # /upgrade 2 | 3 | Upgrade tasks for handling actions that need to occur on startup after a user 4 | installs a new version of the plugin. 5 | -------------------------------------------------------------------------------- /src/upgrade/upgrade.js: -------------------------------------------------------------------------------- 1 | import { keys, setString, getInt, isSet } from '../prefs' 2 | import { error, trace } from '../logger' 3 | 4 | /** 5 | * An ordered list of upgrade tasks. Do NOT change the order of previous 6 | * entries in the array or insert new tasks anywhere but the end of the list. 7 | * The plugin stores an array index to track which tasks have already run. 8 | * the numeric prefix of each task is for convenience only - all that matters 9 | * is the array order. 10 | * 11 | * Note that there is no locking currently, so there is a theoretical race 12 | * condition where multiple upgrades may be run concurrently if multiple plugin 13 | * commands are run simultaneously. This is unlikely: upgrades typically only 14 | * be triggered in the startup handler. But to be on the safe side tasks should 15 | * be idempotent and make no assumptions about the state of the data to be 16 | * upgraded. 17 | */ 18 | const tasks = [ 19 | require('./000_addon_url_to_sketch_atlassian_com') 20 | ] 21 | 22 | /** 23 | * Checks whether there are any upgrade tasks that haven't yet run, and runs 24 | * them if needed. 25 | */ 26 | export default function upgradeIfNeeded () { 27 | let upgradeIndex = 0 28 | if (isSet(keys.nextUpgradeIndex)) { 29 | upgradeIndex = getInt(keys.nextUpgradeIndex) 30 | } 31 | for (var i = upgradeIndex; i < tasks.length; i++) { 32 | try { 33 | tasks[i]() 34 | trace(`Upgrade task ${i} completed successfully`) 35 | } catch (e) { 36 | error(`Failed to execute upgrade task ${i}: ${e}`) 37 | throw e 38 | } 39 | setString(keys.nextUpgradeIndex, i + 1) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /* 2 | * CocoaScript utility functions. 3 | */ 4 | 5 | import { trace } from './logger' 6 | import { readFile } from 'sketch-module-fs' 7 | 8 | /** 9 | * Execute a function, but don't crash Sketch if it throws. This excellent 10 | * pattern borrowed from the Git Sketch plugin. 11 | * 12 | * @param {Object} context provided by Sketch 13 | * @param {function} func a function to execute 14 | */ 15 | export function executeSafely (context, func) { 16 | try { 17 | func(context) 18 | } catch (e) { 19 | createFailAlert(context, 'Error', e) 20 | } 21 | } 22 | 23 | /** 24 | * An async variant of `executeSafely`. 25 | * 26 | * @param {Object} context provided by Sketch 27 | * @param {function} func a function to execute 28 | */ 29 | export async function executeSafelyAsync (context, func) { 30 | try { 31 | await func(context) 32 | } catch (e) { 33 | createFailAlert(context, 'Error', e) 34 | } 35 | } 36 | 37 | /** 38 | * Create a modal error dialog. 39 | * @param {*} context provided by Sketch 40 | * @param {*} title a title for 41 | * @param {*} e the error message 42 | */ 43 | export function createFailAlert (context, title, error) { 44 | trace(error) 45 | const alert = NSAlert.alloc().init() 46 | alert.informativeText = '' + error 47 | alert.messageText = title 48 | alert.addButtonWithTitle('OK') 49 | alert.runModal() 50 | } 51 | 52 | /** 53 | * Open a URL in the user's default browser. 54 | * 55 | * @param {string} urlString a valid url 56 | */ 57 | export function openInBrowser (urlString) { 58 | if (!urlString || !urlString.trim()) { 59 | throw new Error('Can\'t open blank url!') 60 | } 61 | var url = NSURL.URLWithString(urlString) 62 | NSWorkspace.sharedWorkspace().openURL(url) 63 | } 64 | 65 | /** 66 | * Open a file in the user's default app for that file type. 67 | * 68 | * @param {string} filepath the path to a file 69 | */ 70 | export function openInDefaultApp (filepath) { 71 | return NSWorkspace.sharedWorkspace().openFile(filepath) 72 | } 73 | 74 | /** 75 | * Create a temp directory. 76 | * 77 | * @param {string} [name] an optional name for the directory 78 | * @return {string} the path to the temp directory 79 | */ 80 | export function tempDir (name) { 81 | var tmp = NSTemporaryDirectory() + 'jira-sketch-plugin/' 82 | if (name) { 83 | tmp += name + '/' 84 | } 85 | return tmp 86 | } 87 | 88 | /** 89 | * @param {number} max the maximum value 90 | * @return {string} a random hex string 91 | */ 92 | export function randomHex (max) { 93 | return randomInt(max).toString(16) 94 | } 95 | 96 | /** 97 | * @param {number} max the maximum value 98 | * @return {number} a random integer 99 | */ 100 | export function randomInt (max) { 101 | return Math.floor(Math.random() * max) 102 | } 103 | 104 | /** 105 | * @return {string} the user's Downloads directory, or a temp directory if no 106 | * Downloads directory coul be determined. 107 | */ 108 | export function userDownloadsDirectory () { 109 | var dirs = fileManager().URLsForDirectory_inDomains_( 110 | NSDownloadsDirectory, 111 | NSUserDomainMask 112 | ) 113 | if (dirs.length) { 114 | return dirs[0].path() 115 | } else { 116 | return tempDir('downloads') 117 | } 118 | } 119 | 120 | /** 121 | * @param {string} path the path to a file 122 | * @return {Object} the file's attributes 123 | * @see https://developer.apple.com/documentation/foundation/nsfilemanager/1410452-attributesofitematpath?language=objc 124 | */ 125 | export function fileAttributes (path) { 126 | return withErrorPointer(errorPtr => { 127 | return fileManager().attributesOfItemAtPath_error(path, errorPtr) 128 | }) 129 | } 130 | 131 | /** 132 | * @return the default NSFileManager 133 | * @see https://developer.apple.com/documentation/foundation/nsfilemanager 134 | */ 135 | function fileManager () { 136 | return NSFileManager.defaultManager() 137 | } 138 | 139 | /** 140 | * Helper function for dealing with Cocoa methods that use pointers. 141 | * 142 | * @param {funtion} fn a function to be invoked with a single MOPointer 143 | * instance. If the MOPointer has a value set after the funciton completes, 144 | * the value is thrown as an error. 145 | */ 146 | export function withErrorPointer (fn) { 147 | const errorPtr = MOPointer.alloc().init() 148 | const result = fn(errorPtr) 149 | const error = errorPtr.value() 150 | if (error) { 151 | trace(error) 152 | throw new Error(error) 153 | } 154 | return result 155 | } 156 | 157 | /** 158 | * @param {number} delay duration in milliseconds 159 | * @return {Promise} a Promise that resolves after the specified delay 160 | */ 161 | export function sleep (delay) { 162 | return new Promise(function (resolve) { 163 | setTimeout(resolve, delay) 164 | }) 165 | } 166 | 167 | /** 168 | * @param {function} fn the function to retry until an exception is not thrown 169 | * @param {number} maxRetries the number of times to retry (0 == unlimited retries) 170 | * @param {number} delay the delay to wait between retries 171 | * @return {*} the result of the function 172 | * @throws if the function throws more than maxRetries 173 | */ 174 | export async function retryUntilReturn (fn, maxRetries, delay) { 175 | while (true) { 176 | try { 177 | return await fn() 178 | } catch (e) { 179 | if (maxRetries) { 180 | if (--maxRetries) { 181 | await sleep(delay) 182 | } else { 183 | throw e 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * @param {function} fn the function to retry until it returns a truthy result 192 | * @param {number} maxRetries the number of times to retry (0 == unlimited retries) 193 | * @param {number} delay the delay to wait between retries 194 | * @return {*} the result of the function 195 | */ 196 | export async function retryUntilTruthy (fn, maxRetries, delay) { 197 | while (true) { 198 | const result = await fn() 199 | if (result) { 200 | return result 201 | } 202 | if (maxRetries) { 203 | if (--maxRetries <= 0) { 204 | return result 205 | } 206 | } 207 | await sleep(delay) 208 | } 209 | } 210 | 211 | /** 212 | * @returns {string} the path to the plugin's /Resources directory 213 | */ 214 | export function resourcesPath () { 215 | return basePath() + '/Resources/' 216 | } 217 | 218 | /** 219 | * @returns {string} the path to the plugin's /Sketch directory 220 | */ 221 | export function scriptsPath () { 222 | return basePath() + '/Sketch/' 223 | } 224 | 225 | /** 226 | * @returns {string} the path to the plugin's /Contents directory 227 | */ 228 | function basePath () { 229 | return COScript.currentCOScript().env() 230 | .scriptURL.path() 231 | .stringByDeletingLastPathComponent() 232 | .stringByDeletingLastPathComponent() 233 | } 234 | 235 | /** 236 | * @param {string} path the path to a JSON file 237 | * @return {object} a JSON representation of the file 238 | */ 239 | export function readFileAsJson (path) { 240 | return JSON.parse(readFile(path, NSUTF8StringEncoding) + '') 241 | } 242 | 243 | /** 244 | * @param {string} localPath a path to a local file 245 | * @return {string} a normalized & URI encoded path to the specified file 246 | */ 247 | 248 | export function localPathToNSURLString (localPath) { 249 | return encodeURI(NSURL.fileURLWithPath(localPath).path() + '') 250 | } 251 | 252 | /** 253 | * @param {object} context provided by Sketch 254 | * @return {object} the currently selected document 255 | */ 256 | export function documentFromContext (context) { 257 | return context.document || (context.actionContext && context.actionContext.document) || null 258 | } 259 | 260 | export function pluralize (n, singular, plural) { 261 | return n == 1 ? singular : plural 262 | } 263 | -------------------------------------------------------------------------------- /src/views/alerts/README.md: -------------------------------------------------------------------------------- 1 | # /alerts 2 | 3 | Scripts for spawning Cocoa NSAlert dialogs. 4 | -------------------------------------------------------------------------------- /src/views/alerts/keep-or-replace.js: -------------------------------------------------------------------------------- 1 | import { pluralize } from '../../util' 2 | 3 | export const Replace = 'replace' 4 | export const Keep = 'keep' 5 | export const Cancel = 'cancel' 6 | 7 | /** 8 | * Displays an alert prompting the user to Keep or Replace pre-existing Jira 9 | * attachments with names identical to the exported layers. 10 | * @param {Object} context provided by Sketch 11 | * @param {string} issueKey the issue being exported to 12 | * @param {string[]} matchingImages the identical image filenames 13 | */ 14 | export default function (context, issueKey, matchingImages) { 15 | const matches = matchingImages.length 16 | const alert = NSAlert.alloc().init() 17 | alert.informativeText = pluralize(matches, 18 | `An image named '${matchingImages[0]}' is already attached to ${issueKey}`, 19 | `${issueKey} already has ${matches} attachments with names matching your exports` 20 | ) 21 | alert.messageText = `Keep or replace existing ${pluralize(matches, 'attachment', 'attachments')}` 22 | alert.addButtonWithTitle(pluralize(matches, 'Replace', 'Replace All')) 23 | alert.addButtonWithTitle(`Keep ${pluralize(matches, 'Both', 'All')}`) 24 | alert.setIcon( 25 | NSImage.alloc().initWithContentsOfFile( 26 | context.plugin.urlForResourceNamed('upload-alert.png').path() 27 | ) 28 | ) 29 | const responseCode = alert.runModal() 30 | if (responseCode == NSAlertFirstButtonReturn) { 31 | return Replace 32 | } else if (responseCode == NSAlertSecondButtonReturn) { 33 | return Keep 34 | } else { 35 | return Cancel 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/views/bridge/README.md: -------------------------------------------------------------------------------- 1 | # /views/bridge 2 | 3 | Code to bridge function calls between WebViews and the CocoaScript environment. 4 | -------------------------------------------------------------------------------- /src/views/bridge/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Shared constants and helper functions thar are used on both sides of the 3 | * bridge. This file (and any imports) must be both valid CocoaScript and 4 | * JavaScript! 5 | */ 6 | 7 | export const SketchBridgeFunctionResultEvent = 'sketch.bridge.function.result' 8 | export const SketchBridgeFunctionCallbackEvent = 'sketch.bridge.function.callback' 9 | export const SketchBridgeFunctionName = '__bridgedFunction' 10 | export const SketchBridgeFunctionCallback = '__bridgedFunctionCallback' 11 | export const SketchExposedFunctionTriggerEvent = 'sketch.bridge.exposed.function.trigger' 12 | export const SketchExposedFunctionCallback = '__exposedFunctionCallback' 13 | export const SketchBridgeClientInitializedFlag = '__SketchBridgeClientInitialized' 14 | 15 | export function invocationKeyForTests (functionName, arg0, arg1, etc) { 16 | return `${JSON.stringify([].slice.call(arguments))}` 17 | } 18 | -------------------------------------------------------------------------------- /src/views/bridge/host.js: -------------------------------------------------------------------------------- 1 | /* 2 | * The server-side (CocoaScript) of the CocoaScript-WebView bridge. Wraps the 3 | * excellent `sketch-module-web-view` module and should be used to create any 4 | * any NSPanels used by the plugin. 5 | */ 6 | 7 | import WebUI from 'sketch-module-web-view' 8 | import { 9 | SketchBridgeFunctionResultEvent, 10 | SketchBridgeFunctionCallbackEvent, 11 | SketchBridgeFunctionName, 12 | SketchBridgeFunctionCallback, 13 | SketchExposedFunctionTriggerEvent, 14 | SketchExposedFunctionCallback, 15 | SketchBridgeClientInitializedFlag 16 | } from './common' 17 | import { isTraceEnabled, trace } from '../../logger' 18 | import { retryUntilTruthy } from '../../util' 19 | import { assignIn } from 'lodash' 20 | import uuid from 'uuid/v4' 21 | 22 | /** 23 | * @param {Object} context provided by Sketch 24 | * @param {Object} options used to configure the WebUI. Some options are 25 | * documented here, others are passed through to 'sketch-module-web-view' 26 | * @param {function[]} options.handlers an array of handler functions that can 27 | * be invoked from the client-side by ./client#bridgedFunction and 28 | * sketch-module-web-view/client 29 | * @param {string} options.page the name of the HTML file in the `Resources` 30 | * directory to be rendered as the content of the WebView. 31 | * @return {Promise} a WebUI initialized for use with the CocoaScript-JavaScript bridge 32 | */ 33 | export default function createBridgedWebUI (context, options) { 34 | let webUI 35 | /** 36 | * The handler function invoked by ./client#bridgedFunction. Delegates 37 | * on to other handler functions, and translates return values or thrown 38 | * exceptions into custom window events that are handled in ./client. 39 | */ 40 | options.handlers[SketchBridgeFunctionName] = async function (invocationId, handlerFunctionName) { 41 | isTraceEnabled && trace(`${JSON.stringify(arguments)}`) 42 | 43 | const args = [].slice.call(arguments).slice(2).map((arg, callbackIndex) => { 44 | if (arg == SketchBridgeFunctionCallback) { 45 | return function () { 46 | webUI.dispatchWindowEvent(SketchBridgeFunctionCallbackEvent, { 47 | invocationId, 48 | callbackIndex, 49 | args: [].slice.call(arguments) 50 | }) 51 | } 52 | } else { 53 | return arg 54 | } 55 | }) 56 | 57 | let result = null 58 | let error = null 59 | try { 60 | result = await options.handlers[handlerFunctionName](...args) 61 | } catch (e) { 62 | // An error caught here could be a wrapped NSError or a plain old 63 | // JavaScript error. They each need to be serialized a little 64 | // differently. 65 | if (e.class && e.class().isSubclassOfClass(NSError)) { 66 | error = { 67 | error: String.valueOf(e), 68 | name: e.class() + '', 69 | message: e.localizedDescription() + '' 70 | } 71 | } else { 72 | // the default string representation of the error omits most fields 73 | error = assignIn({ 74 | error: String.valueOf(e), 75 | // for some reason, these properties are not copied by assignIn 76 | name: e.name, 77 | message: e.message 78 | }, e) 79 | } 80 | trace(error) 81 | } 82 | webUI.dispatchWindowEvent(SketchBridgeFunctionResultEvent, {invocationId, result, error, handlerFunctionName}) 83 | } 84 | 85 | const exposedFunctionInvocations = {} 86 | 87 | options.handlers[SketchExposedFunctionCallback] = async function (returnValue) { 88 | const { id, functionName, result, error } = returnValue 89 | const invocation = exposedFunctionInvocations[id] 90 | if (!invocation) { 91 | trace(`No exposed function invocation found for id ${id} (${functionName})`) 92 | return 93 | } 94 | if (error) { 95 | invocation.reject(error) 96 | } else { 97 | invocation.resolve(result) 98 | } 99 | delete exposedFunctionInvocations[id] 100 | } 101 | 102 | webUI = new WebUI(context, options.page, options) 103 | 104 | /** 105 | * Trigger a new CustomEvent on the `window` object of the WebView. 106 | * @param {string} eventName the event name 107 | * @param {Object} eventDetail the event payload, used as the `detail` 108 | * property of the event 109 | */ 110 | webUI.dispatchWindowEvent = function (eventName, eventDetail) { 111 | var eventJson = JSON.stringify({ detail: eventDetail }) 112 | isTraceEnabled() && trace(`window event: ${eventName} ${eventJson}`) 113 | webUI.eval( 114 | `window.dispatchEvent(new CustomEvent('${eventName}', ${eventJson}))` 115 | ) 116 | } 117 | 118 | /** 119 | * Invoke a function that has been exposed by ./client#exposeFunction 120 | * @param {string} functionName name of the exposed function 121 | * @param {*...} args arguments to be passed to the exposed function 122 | * @return {Promise<*>} a Promise that will be resolved or rejected with the 123 | * result returned or thrown by the exposed function 124 | */ 125 | webUI.invokeExposedFunction = function (functionName, ...args) { 126 | return new Promise(function (resolve, reject) { 127 | const id = uuid() 128 | exposedFunctionInvocations[id] = {resolve, reject} 129 | webUI.dispatchWindowEvent(SketchExposedFunctionTriggerEvent, { 130 | id, functionName, args 131 | }) 132 | }) 133 | } 134 | 135 | /** 136 | * @return {Promise} a Promise that will be resolved when the client 137 | * indicates that the bridge has been initialized 138 | */ 139 | webUI.waitUntilBridgeInitialized = async function () { 140 | return retryUntilTruthy(() => { 141 | return webUI.eval(`window.${SketchBridgeClientInitializedFlag}`) 142 | }, 0, 100) 143 | } 144 | 145 | return webUI 146 | } 147 | -------------------------------------------------------------------------------- /src/views/controls/README.md: -------------------------------------------------------------------------------- 1 | # /views/controls 2 | 3 | Helpers and handlers for injecting new controls into the Sketch UI. -------------------------------------------------------------------------------- /src/views/controls/button-delegate.js: -------------------------------------------------------------------------------- 1 | import ObjCClass from 'cocoascript-class-babel-safe' 2 | 3 | export const onClickSelector = NSSelectorFromString('onClick:') 4 | 5 | const DelegateClass = ObjCClass({ 6 | classname: 'AtlassianNSButtonDelegate', 7 | callbacks: null, 8 | 'onClick:': function (sender) { 9 | this.callbacks.onClick() 10 | } 11 | }) 12 | 13 | /** 14 | * @param {Object} callbacks callbacks supported by this delegate 15 | * @param {function} callbacks.onClick invoked when the delegate's onClick: 16 | * selector is invoked 17 | * @return a delegate suitable for using as the target of an NSButton 18 | */ 19 | export default function (callbacks) { 20 | const delegate = DelegateClass.new() 21 | delegate.callbacks = NSDictionary.dictionaryWithDictionary(callbacks) 22 | return delegate 23 | } 24 | -------------------------------------------------------------------------------- /src/views/controls/export-button.js: -------------------------------------------------------------------------------- 1 | import { error, trace } from '../../logger' 2 | import { documentFromContext } from '../../util' 3 | import { analytics } from '../../analytics' 4 | import buttonDelegate, { onClickSelector } from './button-delegate' 5 | import launchPanel from '../panels/launch' 6 | 7 | export default { add, remove } 8 | 9 | const jiraButtonToolTip = 'Export to Jira' 10 | const exportButtonOffset = 44 11 | 12 | /** 13 | * *Carefully* try to add an 'Export to Jira' button to Sketch's export panel. 14 | * Care is taken to ensure that if the Sketch UI is not structured as we expect, 15 | * the button simply fails to appear and prints a suitable log message, rather 16 | * than crashing Sketch. See inline below for the documented assumptions about 17 | * the structure of the Sketch UI. 18 | * 19 | * @param {Object} context provided by Sketch 20 | */ 21 | async function add (context) { 22 | withExportButtonBar(context, (exportButtonBar) => { 23 | const exportButtons = exportButtonBar.subviews() 24 | 25 | // Assumption: There are two standard export buttons (plus ours) 26 | if (exportButtons.length == 3) { 27 | const jiraButton = exportButtons[2] 28 | const toolTip = jiraButton.toolTip && jiraButton.toolTip() 29 | if (toolTip && toolTip == jiraButtonToolTip) { 30 | // button already registered \o/ 31 | } else { 32 | error(`Found three export buttons, but the 'Export to Jira' button isn't there`) 33 | } 34 | return 35 | } 36 | 37 | // Assumption: There are two standard export buttons 38 | // Assumption: If they aren't present, the layer is probably collapsed. 39 | if (exportButtons.length != 2) { 40 | trace(`Last subview of MSExportStackView has ${exportButtons.length} subviews`) 41 | return 42 | } 43 | if (exportButtons[0].class() != 'NSButton' || exportButtons[1].class() != 'NSButton') { 44 | trace(`Last subview of MSExportStackView contains non-NSButton children`) 45 | return 46 | } 47 | 48 | // Add the Jira button 49 | const uploadIcon = NSImage.alloc().initWithContentsOfFile( 50 | context.plugin.urlForResourceNamed('jira-icon.png').path() 51 | ) 52 | const jiraButtonDelegate = buttonDelegate({ 53 | onClick: function () { 54 | launchPanel(context) 55 | analytics('openPanelByButton') 56 | } 57 | }) 58 | try { 59 | const jiraButton = NSButton.alloc().initWithFrame(NSMakeRect(110, -2, 56, 32)) 60 | exportButtonBar.addSubview(jiraButton) 61 | jiraButton.setBezelStyle(NSRoundedBezelStyle) 62 | jiraButton.setTarget(jiraButtonDelegate) 63 | jiraButton.setImage(uploadIcon) 64 | jiraButton.setImagePosition(NSImageOnly) 65 | jiraButton.setImageScaling(NSImageScaleProportionallyDown) 66 | jiraButton.setAction(onClickSelector) 67 | jiraButton.setToolTip(jiraButtonToolTip) 68 | } catch (e) { 69 | log('that button aint no gud') 70 | log(e) 71 | throw e 72 | } 73 | 74 | // Adjust the sizing of the 'Export $layerName' button to make room for the Jira button 75 | // Assumption: The first subview is the 'Export $layerName' button 76 | const exportButton = exportButtons[0] 77 | const eFrame = exportButton.frame() 78 | exportButton.setFrame(NSMakeRect( 79 | eFrame.origin.x, 80 | eFrame.origin.y, 81 | eFrame.size.width - exportButtonOffset, 82 | eFrame.size.height 83 | )) 84 | }) 85 | } 86 | 87 | /** 88 | * *Carefully* try to remove the 'Export to Jira' button from Sketch's export 89 | * panel, and restore the UI to its previous state. If the Sketch UI is not 90 | * structured as we expect, bail out and print a suitable log message, rather 91 | * than attempting to remove the button (or crashing Sketch). 92 | * 93 | * @param {Object} context provided by Sketch 94 | */ 95 | async function remove (context) { 96 | withExportButtonBar(context, (exportButtonBar) => { 97 | const exportButtons = exportButtonBar.subviews() 98 | 99 | // Assumption: There are two standard export buttons (plus our Jira button) 100 | // Assumption: If they aren't present, the layer is probably collapsed or the button isn't present 101 | if (exportButtons.length != 3) { 102 | trace(`Last subview MSExportStackView has ${exportButtons.length} subviews`) 103 | return 104 | } 105 | 106 | const jiraButton = exportButtons[2] 107 | const toolTip = jiraButton.toolTip && jiraButton.toolTip() 108 | if (toolTip != jiraButtonToolTip) { 109 | error(`'${jiraButtonToolTip}' button wasn't where it should be`) 110 | return 111 | } 112 | 113 | // Re-adjust the sizing of the 'Export $layerName' button once we remove the Jira button 114 | // Assumption: The first subview is the 'Export $layerName' button 115 | const exportButton = exportButtons[0] 116 | jiraButton.removeFromSuperview() 117 | const eFrame = exportButton.frame() 118 | exportButton.setFrame(NSMakeRect( 119 | eFrame.origin.x, 120 | eFrame.origin.y, 121 | eFrame.size.width + exportButtonOffset, 122 | eFrame.size.height 123 | )) 124 | }) 125 | } 126 | 127 | /** 128 | * Attempt to locate the NSView containing Sketch's export buttons. 129 | * 130 | * @param {Object} context provided by Sketch 131 | * @param {*} callback invoked with the NSView containing Sketch's export buttons. 132 | */ 133 | function withExportButtonBar (context, callback) { 134 | const document = documentFromContext(context) 135 | if (!document) { 136 | return 137 | } 138 | const contentView = document.documentWindow().contentView() 139 | if (!contentView) { 140 | return 141 | } 142 | 143 | // Assumption: the NSView subclass for exports is named 'MSExportStackView' 144 | const exportStackView = findSubviewWithClass(contentView, 'MSExportStackView') 145 | if (!exportStackView) { 146 | trace('Couldn\'t find MSExportStackView') 147 | return 148 | } 149 | 150 | // Assumption: No subviews for 'MSExportStackView' the selection can't be exported (?) 151 | const exportSubviews = exportStackView.subviews() 152 | if (exportSubviews.length == 0) { 153 | trace('MSExportStackView has no subviews') 154 | return 155 | } 156 | 157 | // Assumption: Export button bar is always the last subview of 'MSExportStackView' 158 | return callback(exportSubviews[exportSubviews.length - 1]) 159 | } 160 | 161 | /** 162 | * Recursively walk the subviews of an NSView to find an instance of a 163 | * particular class. Subviews are walked in reverse order, since this is more 164 | * efficient in the current version of Sketch at time of writing. 165 | * 166 | * @param {Object} view the parent NSView to search 167 | * @param {string} clazz the name of the class to be found 168 | */ 169 | function findSubviewWithClass (view, clazz) { 170 | if (view.class() == clazz) { 171 | return view 172 | } 173 | if (view.subviews) { 174 | const subviews = view.subviews() 175 | // Assumption: the Export panel is in the bottom right, so let's walk the tree 176 | // backwards to find it 177 | for (var i = subviews.length - 1; i >= 0; i--) { 178 | const result = findSubviewWithClass(subviews[i], clazz) 179 | if (result) { 180 | return result 181 | } 182 | } 183 | } 184 | return null 185 | } 186 | -------------------------------------------------------------------------------- /src/views/panels/README.md: -------------------------------------------------------------------------------- 1 | # /views/panels 2 | 3 | Wrappers and utilities for displaying Cocoa WebViews, Panels, and Windows. 4 | -------------------------------------------------------------------------------- /src/views/panels/connect.js: -------------------------------------------------------------------------------- 1 | import { createWebUI, ConnectPanelId } from './webui-common' 2 | import { 3 | setJiraUrl, 4 | getJiraHost, 5 | isJiraHostSet, 6 | getAuthorizationUrl, 7 | testAuthorization 8 | } from '../../auth' 9 | import { analytics } from '../../analytics' 10 | import { akGridSizeUnitless } from '@atlaskit/util-shared-styles' 11 | import { titlebarHeight } from './ui-constants' 12 | import openIssuesPanel from './issues' 13 | import { trace } from '../../logger' 14 | 15 | /** 16 | * Spawns the 'Connect' panel for authorizing the user with Jira. 17 | * 18 | * @param {Object} context provided by Sketch 19 | * @return {Object} a WebUI for the launched panel 20 | */ 21 | export default async function (context) { 22 | trace(context) 23 | const webUI = createWebUI(context, ConnectPanelId, 'connect.html', { 24 | width: 44 * akGridSizeUnitless, 25 | // +2 == fudge (lineheights don't quite add up to a multiple of akGridSize) 26 | height: titlebarHeight + 40 * akGridSizeUnitless + 2, 27 | handlers: { 28 | async getJiraUrl () { 29 | if (isJiraHostSet()) { 30 | trace(`jira host is ${getJiraHost()}`) 31 | return getJiraHost() 32 | } else { 33 | trace(`jira host is not set`) 34 | return '' 35 | } 36 | }, 37 | async setJiraUrl (jiraUrl) { 38 | return setJiraUrl(jiraUrl) 39 | }, 40 | async testAuthorization () { 41 | return testAuthorization() 42 | }, 43 | async getAuthorizationUrl () { 44 | return getAuthorizationUrl() 45 | }, 46 | async authorizationComplete () { 47 | webUI.panel.close() 48 | openIssuesPanel(context) 49 | } 50 | } 51 | }) 52 | 53 | analytics('openPanelConnect') 54 | await webUI.waitUntilBridgeInitialized() 55 | return webUI 56 | } 57 | -------------------------------------------------------------------------------- /src/views/panels/helpers/attachments.js: -------------------------------------------------------------------------------- 1 | import { openInDefaultApp, sleep } from '../../../util' 2 | import { trace } from '../../../logger' 3 | import { 4 | thumbnailDownloadConcurrency, 5 | thumbnailRetryDelay, 6 | thumbnailRetryMax 7 | } from '../../../config' 8 | import Queue from 'promise-queue' 9 | import blankThumbnailDataUri from '../../../blank-thumbnail-datauri.txt' 10 | 11 | /** 12 | * Handles operations on attachments that already exist in Jira. 13 | */ 14 | export default class Attachments { 15 | constructor (context, webUI, jira) { 16 | this.context = context 17 | this.webUI = webUI 18 | this.jira = jira 19 | this.thumbnailQueue = new Queue(thumbnailDownloadConcurrency, Infinity) 20 | } 21 | 22 | /** 23 | * @param {string} issueKey identifies the issue to retrieve 24 | * @param {boolean} updateHistory whether to update Jira's 'recent issues' 25 | * list (which is used by the plugin's Recently Viewed filter) 26 | * @param {boolean} suppressError if true, silently ignore any thrown errors 27 | * and return `null` 28 | * @return {Promise} the issue 29 | */ 30 | async getIssue (issueKey, updateHistory, suppressError) { 31 | try { 32 | return await this.jira.getIssue(issueKey, { updateHistory }) 33 | } catch (e) { 34 | if (suppressError) { 35 | return null 36 | } else { 37 | throw e 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * @param {string} url the URL of an attachment thumbnail (extracted from the 44 | * attachments field of an issue object) 45 | * @param {string} mimeType the mime type of the attachment 46 | * @return {Promise} image data URI representation of an attachment thumbnail 47 | */ 48 | async getThumbnail (url, mimeType) { 49 | var attempts = thumbnailRetryMax 50 | let dataUri 51 | do { 52 | dataUri = await this.thumbnailQueue.add(async () => { 53 | return this.jira.getImageAsDataUri(url, mimeType) 54 | }) 55 | if (dataUri != blankThumbnailDataUri) { 56 | break 57 | } 58 | // ASP-13 - Jira often returns blank thumbnails for a short period 59 | // immediately after an attachment is uploaded 60 | trace(`Blank thumbnail! Retry in ${thumbnailRetryDelay} (${attempts} attempts left)`) 61 | await sleep(thumbnailRetryDelay) 62 | } while (--attempts) 63 | return dataUri 64 | } 65 | 66 | /** 67 | * Deletes an issue attachment. 68 | * @param {string} attachmentId identifies the attachment to be deleted 69 | */ 70 | async deleteAttachment (attachmentId) { 71 | await this.jira.deleteAttachment(attachmentId) 72 | } 73 | 74 | /** 75 | * Downloads an attachment into the user's Downloads directory, then 76 | * opens it using the default app for that file type. 77 | * @param {string} url the URL of an attachment's content (extracted from the 78 | * attachments field of an issue object) 79 | * @param {string} filename the desired filename to be saved as on disk 80 | * @param {function} progress a callback function periodically invoked with 81 | * the percentage of download complete (a Number between 0 and 1). 82 | */ 83 | async openAttachment (url, filename, progress) { 84 | const filepath = await this.jira.downloadAttachment(url, filename, (completed, total) => { 85 | progress(completed / total) 86 | }) 87 | openInDefaultApp(filepath) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/views/panels/helpers/filters.js: -------------------------------------------------------------------------------- 1 | import { jqlFilterArray } from '../../../jql-filters' 2 | 3 | /** 4 | * Retrieves filters, and filtered issues. 5 | */ 6 | export default class Filters { 7 | constructor (context, webUI, jira) { 8 | this.context = context 9 | this.webUI = webUI 10 | this.jira = jira 11 | this.currentFilter = null 12 | } 13 | 14 | /** 15 | * @return {Object[]} an array of available filters 16 | */ 17 | loadFilters () { 18 | return jqlFilterArray 19 | } 20 | 21 | /** 22 | * @param {string} newFilter the key of the selected filter 23 | * @return {Promise} an array of issues matching the filter 24 | */ 25 | async onFilterChanged (newFilter) { 26 | this.currentFilter = newFilter 27 | return this.jira.getFilteredIssues(newFilter) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/views/panels/helpers/uploads.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import mime from 'mime-types' 3 | import { 4 | randomHex, 5 | fileAttributes 6 | } from '../../../util' 7 | import { getDraggedFiles } from '../../../pasteboard' 8 | import { isTraceEnabled, trace, error } from '../../../logger' 9 | import { jiraDateMomentFormat, attachmentUploadConcurrency } from '../../../config' 10 | import { executeSafelyAsync } from '../../../util' 11 | import { getSelectedIssueKey } from '../../../plugin-state' 12 | import { exportSelection } from '../../../export' 13 | import { attachmentFromRest } from '../../../entity-mappers' 14 | import Queue from 'promise-queue' 15 | 16 | /** 17 | * Handles uploading files to Jira as attachments. 18 | */ 19 | export default class Uploads { 20 | constructor (context, webUI, jira, attachments) { 21 | this.context = context 22 | this.webUI = webUI 23 | this.jira = jira 24 | this.attachments = attachments 25 | this.uploadQueue = new Queue(attachmentUploadConcurrency, Infinity) 26 | } 27 | 28 | /** 29 | * @return {Object[]} an array of file objects from the system drag 30 | * pasteboard. See `fileUrlToUploadInfo` for documentation regarding the 31 | * object's properties. 32 | */ 33 | getDroppedFiles () { 34 | return getDraggedFiles().map(fileUrlToUploadInfo) 35 | } 36 | 37 | /** 38 | * Export the currently selected layers the currently selected issue using 39 | * the layers' configured export options. 40 | */ 41 | async exportSelectedLayersToSelectedIssue () { 42 | executeSafelyAsync(this.context, async () => { 43 | const issueKey = getSelectedIssueKey() 44 | if (!issueKey) { 45 | error('No issue selected, ignoring export request') 46 | return 47 | } 48 | const paths = await exportSelection(this.context, issueKey) 49 | trace(`Exported paths from selection: ["${paths.join('", "')}"]`) 50 | this.webUI.invokeExposedFunction( 51 | 'exportSelectionToSelectedIssue', issueKey, paths.map(fileUrlToUploadInfo) 52 | ) 53 | }) 54 | } 55 | 56 | /** 57 | * @param {string} issueKey identifies the issue to upload the attachment to 58 | * @param {Object} attachment the attachment to upload 59 | * @param {string} attachment.path the file path to the attachment 60 | * @param {function} progress a callback function periodically invoked with 61 | * the percentage of upload complete (a Number between 0 and 1). 62 | * @return {Promise} the uploaded attachment object 63 | */ 64 | async uploadAttachment (issueKey, attachment, progress) { 65 | return this.uploadQueue.add(async () => { 66 | trace(`attaching ${attachment.path} to ${issueKey}`) 67 | const resp = await this.jira.uploadAttachment( 68 | issueKey, 69 | attachment.path, 70 | (completed, total) => { 71 | progress(completed / total) 72 | } 73 | ) 74 | const json = await resp.json() 75 | if (isTraceEnabled()) { 76 | trace(JSON.stringify(json)) 77 | } 78 | return attachmentFromRest(json[0]) 79 | }) 80 | } 81 | } 82 | 83 | /** 84 | * @param {string} fileUrlString a local file url 85 | * @return {Object} an attachment object representing the file being uploaded 86 | */ 87 | function fileUrlToUploadInfo (fileUrlString) { 88 | const fileUrl = NSURL.URLWithString(fileUrlString) 89 | const extension = fileUrl.pathExtension() + '' 90 | const path = fileUrl.path() + '' 91 | const attributes = fileAttributes(path) 92 | return { 93 | id: randomHex(0xffffffff), 94 | filename: fileUrl.lastPathComponent() + '', 95 | path, 96 | created: moment().format(jiraDateMomentFormat), 97 | extension, 98 | size: parseInt(attributes[NSFileSize] + ''), 99 | mimeType: mime.lookup(extension) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/views/panels/issues.js: -------------------------------------------------------------------------------- 1 | import { createWebUI, IssuePanelId } from './webui-common' 2 | import Filters from './helpers/filters' 3 | import Uploads from './helpers/uploads' 4 | import Attachments from './helpers/attachments' 5 | import { analytics } from '../../analytics' 6 | import { feedbackUrl } from '../../config' 7 | import { openInBrowser } from '../../util' 8 | import { akGridSizeUnitless } from '@atlaskit/util-shared-styles' 9 | import { titlebarHeight } from './ui-constants' 10 | import Jira from '../../jira' 11 | import openConnectPanel from './connect' 12 | import keepOrReplaceAlert from '../alerts/keep-or-replace' 13 | import { 14 | setSelectedIssueKey 15 | } from '../../plugin-state' 16 | import { 17 | setLastViewedIssueForDocument, 18 | getLastExportedIssueForSelectedLayers, 19 | areLayersSelected 20 | } from '../../export' 21 | 22 | const issueListDimensions = [ 23 | akGridSizeUnitless * 64, 24 | akGridSizeUnitless * 45 + titlebarHeight 25 | ] 26 | 27 | const issueViewDimensions = [ 28 | akGridSizeUnitless * 64, 29 | akGridSizeUnitless * 50 30 | ] 31 | 32 | /** 33 | * Spawns the 'Jira' panel for browsing and interacting with Jira issues. 34 | * 35 | * @param {Object} context provided by Sketch 36 | * @return {Object} a WebUI for the launched panel 37 | */ 38 | export default async function (context) { 39 | const webUI = createWebUI(context, IssuePanelId, 'issues.html', { 40 | width: issueListDimensions[0], 41 | height: issueListDimensions[1], 42 | onClose: function () { 43 | setSelectedIssueKey(null) 44 | }, 45 | handlers: { 46 | async loadFilters () { 47 | return filters.loadFilters() 48 | }, 49 | loadProfile () { 50 | return jira.getProfile() 51 | }, 52 | loadIssuesForFilter (filterKey) { 53 | return filters.onFilterChanged(filterKey) 54 | }, 55 | /** 56 | * Determines an appropriate issue to preselect in the issue panel, based 57 | * on the user's past exports for the current selection or document. 58 | * 59 | * @return {string} an issue key identifying the issue to preselect 60 | */ 61 | getSuggestedPreselectedIssueKey () { 62 | return getLastExportedIssueForSelectedLayers(context) 63 | /* Only suggest issues when layers are selected, for now */ 64 | /* || getLastViewedIssueForDocument(context) */ 65 | }, 66 | getDroppedFiles () { 67 | return uploads.getDroppedFiles() 68 | }, 69 | exportSelectedLayers () { 70 | uploads.exportSelectedLayersToSelectedIssue() 71 | }, 72 | uploadAttachment (issueKey, attachment, progress) { 73 | return uploads.uploadAttachment(issueKey, attachment, progress) 74 | }, 75 | promptKeepOrReplace (issueKey, matchingImages) { 76 | return keepOrReplaceAlert(context, issueKey, matchingImages) 77 | }, 78 | getIssue (issueKey, updateHistory, suppressError) { 79 | return attachments.getIssue(issueKey, updateHistory, suppressError) 80 | }, 81 | onIssueSelected (issueKey) { 82 | webUI.resizePanel(...issueViewDimensions) 83 | setSelectedIssueKey(issueKey) 84 | setLastViewedIssueForDocument(context, issueKey) 85 | }, 86 | onIssueDeselected (issueKey) { 87 | webUI.resizePanel(...issueListDimensions) 88 | setSelectedIssueKey(null) 89 | setLastViewedIssueForDocument(context, null) 90 | }, 91 | areLayersSelected () { 92 | return areLayersSelected(context) 93 | }, 94 | getWatchers (issueKey) { 95 | return jira.getWatchers(issueKey) 96 | }, 97 | getThumbnail (url, mimeType) { 98 | return attachments.getThumbnail(url, mimeType) 99 | }, 100 | openAttachment (url, filename, progress) { 101 | return attachments.openAttachment(url, filename, progress) 102 | }, 103 | deleteAttachment (id) { 104 | return attachments.deleteAttachment(id) 105 | }, 106 | addComment (issueKey, comment) { 107 | return jira.addComment(issueKey, comment) 108 | }, 109 | findUsersForPicker (query) { 110 | return jira.findUsersForPicker(query) 111 | }, 112 | viewSettings () { 113 | webUI.panel.close() 114 | openConnectPanel(context) 115 | }, 116 | reauthorize () { 117 | webUI.panel.close() 118 | openConnectPanel(context) 119 | }, 120 | feedback () { 121 | openInBrowser(feedbackUrl) 122 | } 123 | } 124 | }) 125 | 126 | const jira = new Jira() 127 | const filters = new Filters(context, webUI, jira) 128 | const attachments = new Attachments(context, webUI, jira) 129 | const uploads = new Uploads(context, webUI, jira, attachments) 130 | 131 | analytics('openPanelIssues') 132 | await webUI.waitUntilBridgeInitialized() 133 | return webUI 134 | } 135 | -------------------------------------------------------------------------------- /src/views/panels/launch.js: -------------------------------------------------------------------------------- 1 | import { executeSafelyAsync } from '../../util' 2 | import { isAuthorized } from '../../auth' 3 | import connectPanel from './connect' 4 | import issuesPanel from './issues' 5 | import { closeAllPluginPanels } from './webui-common' 6 | 7 | /** 8 | * If the user isn't already authorized, opens the Connect panel to start the 9 | * authorization process with Jira. Otherwise opens the Jira panel. 10 | * 11 | * @param {Object} context provided by Sketch 12 | */ 13 | export default async function (context) { 14 | executeSafelyAsync(context, async function () { 15 | closeAllPluginPanels() 16 | if (isAuthorized()) { 17 | issuesPanel(context) 18 | } else { 19 | connectPanel(context) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/views/panels/panel-delegate.js: -------------------------------------------------------------------------------- 1 | import ObjCClass from 'cocoascript-class-babel-safe' 2 | 3 | const DelegateClass = ObjCClass({ 4 | classname: 'AtlassianNSPanelDelegate', 5 | callbacks: null, 6 | 'windowWillClose:': function (notification) { 7 | this.callbacks.onClose() 8 | } 9 | }) 10 | 11 | /** 12 | * @param {Object} callbacks callbacks supported by this delegate 13 | * @param {function} callbacks.onClose invoked when the delegate's 14 | * `windowWillClose:` selector is invoked 15 | * @return a delegate suitable for use as an NSWindowDelegate 16 | */ 17 | export default function (callbacks) { 18 | const delegate = DelegateClass.new() 19 | delegate.callbacks = NSDictionary.dictionaryWithDictionary(callbacks) 20 | return delegate 21 | } 22 | -------------------------------------------------------------------------------- /src/views/panels/ui-constants.js: -------------------------------------------------------------------------------- 1 | export const titlebarHeight = 22 // pixels 2 | -------------------------------------------------------------------------------- /src/views/panels/webui-common.js: -------------------------------------------------------------------------------- 1 | import { assign } from 'lodash' 2 | import { executeSafely, openInBrowser } from '../../util' 3 | import * as config from '../../config' 4 | import { trace } from '../../logger' 5 | import createBridgedWebUI from '../bridge/host' 6 | import { analytics, analyticsBatch } from '../../analytics' 7 | import { titlebarHeight } from './ui-constants' 8 | import panelDelegate from './panel-delegate' 9 | 10 | /** 11 | * Encapsulates common options used by the plugin's panels. 12 | * 13 | * Important to note: 14 | * 15 | * stringByEvaluatingJavaScriptFromString (used by WebUI.eval) has limits: 16 | * 17 | * - JavaScript allocations greater than 10MB are not allowed 18 | * - JavaScript that takes longer than 10 seconds to execute is not allowed 19 | * 20 | * In the former, you'll get an exception generated, but in the latter it may 21 | * well fail 'silently'. 22 | * 23 | * via https://stackoverflow.com/a/7389032 24 | * 25 | * @param {Object} context provided by Sketch 26 | * @param {string} identifier a unique identifier for the WebUI. Note: 27 | * `sketch-module-web-view` stores active NSPanel objects under this ID in the 28 | * main thread's threadDictionary 29 | * @param {string} page the name of the HTML file in the `Resources` directory 30 | * to be rendered as the content of the WebView. 31 | * @param {Object} options used to configure the WebUI. Some options are 32 | * documented here, some are passed through to `createBridgedWebUI` (which 33 | * also passes some on to `sketch-module-web-view`). Sorry for the multiple 34 | * levels of abstraction here - hopefully we'll be able to incorporate some of 35 | * this into `sketch-module-web-view` 36 | * @param {string} options.backgroundColor a hex color string (e.g. '#abadab') 37 | * @param {boolean} options.hideTitleBar whether the panel's title bar should 38 | * be hidden 39 | * @param {number} options.width the width of the panel in pixels 40 | * @param {number} options.height the height of the panel in pixels 41 | * @param {function} [options.onClose] invoked when the panel is closed 42 | * @return {Promise} a WebUI initialized with the provided options 43 | */ 44 | export function createWebUI (context, identifier, page, options) { 45 | // default options 46 | options = assign( 47 | { 48 | identifier, 49 | page, 50 | onlyShowCloseButton: true, 51 | hideTitleBar: false, 52 | title: ' ', 53 | styleMask: NSTitledWindowMask | NSClosableWindowMask 54 | }, 55 | options 56 | ) 57 | 58 | if (options.backgroundColor) { 59 | options.background = MSImmutableColor.colorWithSVGString( 60 | options.backgroundColor 61 | ).NSColorWithColorSpace(null) 62 | } 63 | 64 | // default handlers 65 | options.handlers = assign( 66 | { 67 | analytics (eventName, properties) { 68 | analytics(eventName, properties) 69 | }, 70 | analyticsBatch (events) { 71 | analyticsBatch(events) 72 | }, 73 | openInBrowser (url) { 74 | executeSafely(context, function () { 75 | openInBrowser(url) 76 | }) 77 | }, 78 | openFaqPage (topic) { 79 | executeSafely(context, function () { 80 | openInBrowser(`${config.jiraSketchIntegrationFaqUrl}#${topic}`) 81 | }) 82 | }, 83 | resizePanel (width, height, animate) { 84 | webUI.resizePanel(width, height, animate) 85 | }, 86 | config () { 87 | return config 88 | } 89 | }, 90 | options.handlers 91 | ) 92 | 93 | const webUI = createBridgedWebUI(context, options) 94 | 95 | /** 96 | * @param {number} width the new panel width in pixels 97 | * @param {number} height the new panel height in pixels 98 | * @param {boolean} [animate] animate the resize (generally doesn't look good 99 | * unless you've specifically built the client-side to support it) 100 | */ 101 | webUI.resizePanel = function (width, height, animate) { 102 | // resize WebView 103 | const webViewFrame = webUI.webView.frame() 104 | webUI.webView.setFrame(NSMakeRect( 105 | webViewFrame.origin.x, 106 | options.hideTitleBar ? -titlebarHeight : 0, 107 | width, 108 | height - (options.hideTitleBar ? 0 : titlebarHeight) 109 | )) 110 | 111 | // resize NSPanel 112 | const panelFrame = webUI.panel.frame() 113 | const newPanelY = panelFrame.origin.y + panelFrame.size.height - height 114 | webUI.panel.setFrame_display_animate(NSMakeRect(panelFrame.origin.x, newPanelY, width, height), true, animate) 115 | } 116 | 117 | // ASP-12: workaround incorrect sizing in sketch-module-web-view 118 | webUI.resizePanel(options.width, options.height) 119 | 120 | // default panel behaviour 121 | // webUI.panel.hidesOnDeactivate = false 122 | // webUI.panel.setLevel(NSNormalWindowLevel) 123 | 124 | webUI.panel.delegate = panelDelegate({ 125 | onClose: () => { 126 | trace(`Panel closed: ${identifier}`) 127 | NSThread.mainThread().threadDictionary().removeObjectForKey(identifier) 128 | options.onClose && options.onClose() 129 | webUI.webView.close() 130 | } 131 | }) 132 | 133 | return webUI 134 | } 135 | 136 | /** 137 | * `sketch-module-web-view` stores active NSPanel objects under this ID in the 138 | * main thread's threadDictionary. This function retrieves them again. 139 | * 140 | * @param {string} id the identifier used to create an NSPanel 141 | * @return {Object} the NSPanel 142 | */ 143 | export function findPanel (id) { 144 | return NSThread.mainThread().threadDictionary()[id] 145 | } 146 | 147 | /** 148 | * Look up an NSPanel by id, then close it if it exists. 149 | * 150 | * @param {string} id the identifier used to create an NSPanel 151 | */ 152 | export function closePanel (id) { 153 | const panel = findPanel(id) 154 | if (panel) { 155 | try { 156 | panel.close() 157 | } catch (e) { 158 | trace(`Exception raised when closing window (already closed?): ${e}`) 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Close all known plugin panels. 165 | */ 166 | export function closeAllPluginPanels () { 167 | closePanel(IssuePanelId) 168 | closePanel(ConnectPanelId) 169 | } 170 | 171 | const panelPrefix = 'atlassian-sketch-plugin' 172 | 173 | /** 174 | * The identifier for the 'Jira' panel 175 | */ 176 | export const IssuePanelId = `${panelPrefix}-issues` 177 | /** 178 | * The identifier for the 'Connect' panel 179 | */ 180 | export const ConnectPanelId = `${panelPrefix}-connect` 181 | -------------------------------------------------------------------------------- /src/views/web/README.md: -------------------------------------------------------------------------------- 1 | # /views/web 2 | 3 | This directory contains client-side code, including MobX models and React 4 | components, that will be rendered within a Cocoa WebView. JavaScript in this 5 | package will run in a browser environment (Safari), unlike the rest of the 6 | plugin which runs in a CocoaScript (Mocha) context. 7 | -------------------------------------------------------------------------------- /src/views/web/connect/README.md: -------------------------------------------------------------------------------- 1 | # /views/web/connect 2 | 3 | React components and MobX decorated classes for the 'Connect' panel. -------------------------------------------------------------------------------- /src/views/web/connect/connect.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import React, { Component } from 'react' 3 | import ReactDOM from 'react-dom' 4 | import PropTypes from 'prop-types' 5 | import { observer } from 'mobx-react' 6 | import TextField from '@atlaskit/field-text' 7 | import ButtonGroup from '@atlaskit/button-group' 8 | import Button from '@atlaskit/button' 9 | import Spinner from '@atlaskit/spinner' 10 | import ErrorIcon from '@atlaskit/icon/glyph/error' 11 | import Banner from '@atlaskit/banner' 12 | import styled from 'styled-components' 13 | import '@atlaskit/css-reset' 14 | import { 15 | akGridSizeUnitless, 16 | akColorB500, 17 | akColorN800, 18 | akFontFamily 19 | } from '@atlaskit/util-shared-styles' 20 | import ViewModel from './model' 21 | 22 | @observer 23 | class Connect extends Component { 24 | constructor (props) { 25 | super(props) 26 | this.handleJiraUrlChange = this.handleJiraUrlChange.bind(this) 27 | this.handleSubmit = this.handleSubmit.bind(this) 28 | this.handleMoreInfoClick = this.handleMoreInfoClick.bind(this) 29 | } 30 | render () { 31 | const { 32 | initializing, 33 | loading, 34 | error, 35 | errorMessage, 36 | truncatedErrorMessage, 37 | jiraUrl, 38 | authUrl 39 | } = this.props.viewmodel 40 | const isButtonEnabled = jiraUrl.trim() || authUrl 41 | return ( 42 |
43 | 44 | 45 | Atlassian logo 46 | 47 | {initializing || ( 48 | 49 | Connect to your Atlassian site 50 | 51 | You’re almost ready to upload designs to your team’s Atlassian site. 52 | 53 | 54 | 55 | 64 | 65 | 66 | {loading && ()} 67 | 68 | 69 |
70 | 71 | 79 | 80 |
81 | )} 82 |
83 | 84 | } isOpen={error && true} appearance='error'> 85 | 86 | {truncatedErrorMessage} 87 | 88 | {error && error.faqTopic && ( 89 | More info 90 | )} 91 | 92 | 93 |
94 | ) 95 | } 96 | handleJiraUrlChange (event) { 97 | this.props.viewmodel.jiraUrl = event.target.value 98 | } 99 | handleSubmit (event) { 100 | event.preventDefault() 101 | this.props.viewmodel.connect() 102 | } 103 | handleMoreInfoClick () { 104 | this.props.viewmodel.moreInfo() 105 | } 106 | } 107 | 108 | Connect.propTypes = { 109 | viewmodel: PropTypes.object.isRequired 110 | } 111 | 112 | const ConnectPanel = styled.div` 113 | font-family: ${akFontFamily}; 114 | ` 115 | const CharlieBanner = styled.div` 116 | display: flex; 117 | align-items: center; 118 | justify-content: center; 119 | height: ${akGridSizeUnitless * 9}px; 120 | background-color: ${akColorB500}; 121 | ` 122 | // the no-op -webkit-transform works around a render bug in safari 123 | const ConnectForm = styled.form` 124 | -webkit-transform: translate3d(0,0,0); 125 | padding: 126 | ${akGridSizeUnitless}px 127 | ${akGridSizeUnitless * 3}px 128 | ${akGridSizeUnitless * 3}px 129 | ${akGridSizeUnitless * 3}px 130 | ; 131 | ` 132 | const ConnectHeader = styled.h3` 133 | font-size: 16px; 134 | font-weight: 500; 135 | letter-spacing: -0.006em; 136 | color: ${akColorN800}; 137 | ` 138 | const ConnectParagraph = styled.p` 139 | font-size: 14px; 140 | font-weight: 400; 141 | letter-spacing -0.006em; 142 | color: ${akColorN800}; 143 | ` 144 | const ConnectFields = styled.div` 145 | display: flex; 146 | align-items: flex-end; 147 | justify-content: flex-start; 148 | ` 149 | const TextFieldWrapper = styled.div` 150 | flex-grow: 1; 151 | ` 152 | const SpinnerWrapper = styled.div` 153 | margin-left: ${akGridSizeUnitless}px; 154 | margin-bottom: 2px; 155 | width: 30px; 156 | ` 157 | const BannerWrapper = styled.div` 158 | width: 100%; 159 | position: fixed; 160 | top: 0; 161 | left: 0; 162 | z-index: 10; 163 | -webkit-transform: translate3d(0,0,0); 164 | ` 165 | const ClickableSpan = styled.span` 166 | margin-left: ${akGridSizeUnitless}px; 167 | text-decoration: underline; 168 | cursor: pointer; 169 | ` 170 | 171 | ReactDOM.render(, document.getElementById('container')) 172 | -------------------------------------------------------------------------------- /src/views/web/connect/model/index.js: -------------------------------------------------------------------------------- 1 | import { observable, computed } from 'mobx' 2 | import { bridgedFunction, markBridgeAsInitialized } from '../../../bridge/client' 3 | import { analytics, truncateWithEllipsis, retryUntilTruthy } from '../../util' 4 | 5 | const _getJiraUrl = bridgedFunction('getJiraUrl') 6 | const _setJiraUrl = bridgedFunction('setJiraUrl') 7 | const _authorizationComplete = bridgedFunction('authorizationComplete') 8 | const _testAuthorization = bridgedFunction('testAuthorization') 9 | const _getAuthorizationUrl = bridgedFunction('getAuthorizationUrl') 10 | const _openInBrowser = bridgedFunction('openInBrowser') 11 | const _openFaqPage = bridgedFunction('openFaqPage') 12 | const _config = bridgedFunction('config') 13 | 14 | const maxErrorMessageLength = 30 15 | 16 | export default class ViewModel { 17 | @observable initializing = true 18 | @observable loading = false 19 | @observable error = null 20 | @observable jiraUrl = '' 21 | @observable authUrl = null 22 | @observable config = null 23 | 24 | constructor () { 25 | this.init() 26 | } 27 | 28 | async init () { 29 | markBridgeAsInitialized() 30 | this.config = await _config() 31 | this.jiraUrl = await _getJiraUrl() 32 | this.initializing = false 33 | } 34 | 35 | async connect () { 36 | if (this.authUrl && !this.error) { 37 | // if the user presses 'Connect' again, reopen the auth page 38 | analytics('clickConnectAgain') 39 | return _openInBrowser(this.authUrl) 40 | } 41 | 42 | this.error = null 43 | this.authUrl = null 44 | this.loading = true 45 | 46 | try { 47 | await _setJiraUrl(this.jiraUrl) 48 | if (await _testAuthorization()) { 49 | analytics('clickConnectAlreadyAuthorized') 50 | } else { 51 | this.authUrl = await _getAuthorizationUrl() 52 | _openInBrowser(this.authUrl) 53 | analytics('clickConnectStartDance') 54 | await retryUntilTruthy( 55 | _testAuthorization, 56 | 0, 57 | this.config.userAuthorizationPollInterval 58 | ) 59 | analytics('clickConnectFinishDance') 60 | } 61 | await _authorizationComplete() 62 | } catch (e) { 63 | this.error = e 64 | this.loading = false 65 | analytics(`connectError_${e.name}`) 66 | } 67 | } 68 | 69 | @computed get truncatedErrorMessage () { 70 | return truncateWithEllipsis(this.errorMessage, maxErrorMessageLength) 71 | } 72 | 73 | @computed get errorMessage () { 74 | return this.error && (this.error.message || this.error.name) 75 | } 76 | 77 | async moreInfo () { 78 | if (this.error && this.error.faqTopic) { 79 | analytics('openFaq_' + this.error.faqTopic) 80 | _openFaqPage(this.error.faqTopic) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/views/web/issues/README.md: -------------------------------------------------------------------------------- 1 | # /views/web/issues 2 | 3 | React components and MobX decorated classes for the main 'Jira' panel. -------------------------------------------------------------------------------- /src/views/web/issues/components/AssigneeAvatar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { observer } from 'mobx-react' 4 | import Avatar from '@atlaskit/avatar' 5 | import styled from 'styled-components' 6 | import '@atlaskit/css-reset' 7 | 8 | @observer 9 | export default class AssigneeAvatar extends Component { 10 | render () { 11 | var assignee = this.props.assignee 12 | var avatarUrl, title 13 | if (assignee) { 14 | avatarUrl = assignee.avatarUrls['24x24'] 15 | title = `Assigned to ${assignee.displayName || assignee.name}` 16 | } else { 17 | title = 'Unassigned' 18 | } 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | } 28 | 29 | AssigneeAvatar.propTypes = { 30 | /* 31 | { 32 | "self": "https://jira.example.com/rest/api/2/user?username=admin", 33 | "name": "admin", 34 | "key": "admin", 35 | "accountId": "xxxxxxxx", 36 | "emailAddress": "example@example.com", 37 | "avatarUrls": { 38 | "48x48": "https://...", 39 | "24x24": "https://...", 40 | "16x16": "https://...", 41 | "32x32": "https://..." 42 | }, 43 | "displayName": "Tim Pettersen", 44 | "active": true, 45 | "timeZone": "Australia/Sydney" 46 | } 47 | */ 48 | assignee: PropTypes.object 49 | } 50 | 51 | const AvatarDiv = styled.div` 52 | width: 24px; 53 | ` 54 | 55 | const AvatarWrapper = styled.div` 56 | margin-top: 5px; 57 | margin-right: 5px; 58 | ` 59 | -------------------------------------------------------------------------------- /src/views/web/issues/components/Attachment.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { observer } from 'mobx-react' 4 | import styled from 'styled-components' 5 | import { CardView } from '@atlaskit/media-card' 6 | import moment from 'moment' 7 | 8 | @observer 9 | export default class Attachment extends Component { 10 | constructor (props) { 11 | super(props) 12 | this.dragEnter = this.dragEnter.bind(this) 13 | this.dragLeave = this.dragLeave.bind(this) 14 | this.drop = this.drop.bind(this) 15 | this.state = { 16 | dragHover: 0 17 | } 18 | } 19 | render () { 20 | var attachment = this.props.attachment 21 | const style = {} 22 | if (attachment.animatingDelete) { 23 | style.transition = `all ${attachment.deleteAnimationDelay}s ease-in` 24 | style.transform = 'scale(0,0)' 25 | } 26 | return ( 27 | { event.preventDefault() }} 33 | onDropCapture={this.drop} 34 | > 35 | 0} 38 | /> 39 | 40 | ) 41 | } 42 | dragEnter (event) { 43 | this.setState(function (prevState) { 44 | return { dragHover: prevState.dragHover + 1 } 45 | }) 46 | } 47 | dragLeave (event) { 48 | this.setState(function (prevState) { 49 | return { dragHover: prevState.dragHover - 1 } 50 | }) 51 | } 52 | drop (event) { 53 | event.preventDefault() 54 | this.setState({ dragHover: false }) 55 | if (this.props.attachment.readyForAction) { 56 | this.props.issue.uploadDroppedFiles(this.props.attachment) 57 | } 58 | } 59 | } 60 | 61 | Attachment.propTypes = { 62 | attachment: PropTypes.object.isRequired, 63 | issue: PropTypes.object.isRequired 64 | } 65 | 66 | /* 67 | The duplicated .attachment-delete-button is intentional. It makes the 68 | selector more specific, ensuring the display:block; takes precedence. 69 | */ 70 | const AttachmentWrapper = styled.div` 71 | margin-bottom: 16px; 72 | margin-left: 16px; 73 | &:nth-of-type(3n-1) { 74 | margin-left: 0px; 75 | } 76 | position: relative; 77 | &:hover { 78 | > .attachment-delete-button.attachment-delete-button { 79 | display: block; 80 | } 81 | } 82 | ` 83 | 84 | @observer 85 | class AttachmentCard extends Component { 86 | constructor (props) { 87 | super(props) 88 | this.handleClick = this.handleClick.bind(this) 89 | this.handleDeleteAction = this.handleDeleteAction.bind(this) 90 | } 91 | render () { 92 | var {attachment, dragHover} = this.props 93 | var imageMetadata = { 94 | id: attachment.id, 95 | mediaType: 'image', 96 | mimeType: attachment.mimeType, 97 | name: attachment.filename, 98 | size: attachment.size, 99 | creationDate: moment(attachment.created).valueOf() 100 | } 101 | // this is bit of a hack until CardView supports a downloading status 102 | if (attachment.downloading) { 103 | imageMetadata.name = 'Downloading...' 104 | } 105 | var style = {} 106 | var actions = [] 107 | if (attachment.readyForAction) { 108 | if (dragHover) { 109 | style.padding = '0' 110 | style.border = '1px solid #FFAB00' 111 | } 112 | actions.push({ 113 | label: 'Delete', 114 | type: 'delete', 115 | handler: this.handleDeleteAction 116 | }) 117 | } 118 | return ( 119 | 120 | 130 | {dragHover && ( 131 | 132 | 133 | Replace 134 | Replace 135 | 136 | 137 | )} 138 | 139 | ) 140 | } 141 | handleClick (event) { 142 | event.preventDefault() 143 | this.props.attachment.open() 144 | } 145 | handleDeleteAction (event) { 146 | this.props.attachment.delete() 147 | } 148 | } 149 | 150 | AttachmentCard.propTypes = { 151 | attachment: PropTypes.object.isRequired, 152 | dragHover: PropTypes.bool.isRequired 153 | } 154 | 155 | const CardWrapper = styled.div` 156 | width: 141px; 157 | padding: 1px; 158 | position: relative; 159 | ` 160 | 161 | const ReplaceHover = styled.div` 162 | display: flex; 163 | align-items: flex-end; 164 | width: 100%; 165 | height: 100%; 166 | z-index: 10; 167 | position: absolute; 168 | top: 0; 169 | left: 0; 170 | background: rgba(9, 30, 66, 0.5); 171 | color: #FFFFFF; 172 | ` 173 | 174 | const ReplaceLabel = styled.div` 175 | display: flex; 176 | align-items: center; 177 | padding: 0 0 7px 9px; 178 | ` 179 | const ReplaceLabelText = styled.div` 180 | font-weight: 500; 181 | font-size: 16px; 182 | letter-spacing: -0.07; 183 | margin-left: 8px; 184 | ` 185 | -------------------------------------------------------------------------------- /src/views/web/issues/components/Attachments.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { observer } from 'mobx-react' 4 | import DropZone from './DropZone' 5 | import styled from 'styled-components' 6 | import Attachment from './Attachment' 7 | 8 | @observer 9 | export default class Attachments extends Component { 10 | render () { 11 | const { issue } = this.props 12 | return ( 13 | 14 | 15 | {issue.attachments.map(attachment => ( 16 | attachment.visible && 17 | 22 | ))} 23 | 24 | ) 25 | } 26 | } 27 | 28 | const AttachmentsArea = styled.div` 29 | padding-top: 10px; 30 | display: flex; 31 | flex-wrap: wrap; 32 | justify-content: flex-start; 33 | align-content: flex-start; 34 | ` 35 | 36 | Attachments.propTypes = { 37 | issue: PropTypes.object.isRequired 38 | } 39 | -------------------------------------------------------------------------------- /src/views/web/issues/components/Breadcrumbs.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { observer } from 'mobx-react' 4 | import styled from 'styled-components' 5 | import ChevronLeftIcon from '@atlaskit/icon/glyph/chevron-left' 6 | import Lozenge from '@atlaskit/lozenge' 7 | import { akColorN100, akGridSizeUnitless } from '@atlaskit/util-shared-styles' 8 | 9 | @observer 10 | export default class Breadcrumbs extends Component { 11 | constructor (props) { 12 | super(props) 13 | this.handleClickBack = this.handleClickBack.bind(this) 14 | } 15 | render () { 16 | const filter = this.props.viewmodel.filters.selected 17 | const issue = this.props.viewmodel.issues.selected 18 | return ( 19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 | {'\u00A0'} 27 | {filter.displayName} 28 | 29 |
30 | / 31 | 32 |
33 | 34 | {issue.status.name} 35 | 36 |
37 | ) 38 | } 39 | handleClickBack () { 40 | this.props.viewmodel.deselectIssue() 41 | } 42 | } 43 | 44 | Breadcrumbs.propTypes = { 45 | viewmodel: PropTypes.object.isRequired 46 | } 47 | 48 | const BreadcrumbsWrapper = styled.div` 49 | display: flex; 50 | align-items: center; 51 | justify-content: space-between; 52 | ` 53 | const Left = styled.div` 54 | display: flex; 55 | align-items: center; 56 | ` 57 | const Right = styled.div` 58 | padding-right: 4px; 59 | ` 60 | const BackLink = styled.div` 61 | display: flex; 62 | align-items: center; 63 | padding-top: 1px; 64 | margin-left: -2px; 65 | cursor: pointer; 66 | font-size: 12px 67 | font-weight: 600 68 | character-spacing: 0 69 | color: #5E6C84 70 | ` 71 | const BackTextWrapper = styled.div` 72 | height: 19px; 73 | ` 74 | const Separator = styled.div` 75 | color: ${akColorN100}; 76 | padding-left: ${akGridSizeUnitless}px; 77 | text-align: center; 78 | width: ${akGridSizeUnitless}px; 79 | font-size: 16px; 80 | height: 24px; 81 | ` 82 | 83 | class IssueKey extends Component { 84 | constructor (props) { 85 | super(props) 86 | this.handleClick = this.handleClick.bind(this) 87 | } 88 | render () { 89 | return ( 90 | 94 | 95 | {this.props.issue.key} 96 | 97 | ) 98 | } 99 | handleClick () { 100 | this.props.issue.openInBrowser() 101 | } 102 | } 103 | 104 | IssueKey.propTypes = { 105 | issue: PropTypes.object.isRequired 106 | } 107 | 108 | const IssueKeyLink = styled.div` 109 | cursor: pointer; 110 | margin-left: ${akGridSizeUnitless}px; 111 | color: #7a869a; 112 | font-size: 12px; 113 | font-weight: 600 114 | display: flex; 115 | align-items: center; 116 | ` 117 | 118 | class IssueType extends Component { 119 | render () { 120 | var type = this.props.type 121 | return ( 122 | 127 | ) 128 | } 129 | } 130 | 131 | IssueType.propTypes = { 132 | type: PropTypes.object.isRequired 133 | } 134 | 135 | const TypeIcon = styled.img` 136 | margin-right: 4px; 137 | ` 138 | -------------------------------------------------------------------------------- /src/views/web/issues/components/CommentEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { observer } from 'mobx-react' 4 | import styled from 'styled-components' 5 | import FieldBase from '@atlaskit/field-base' 6 | import { MentionList } from '@atlaskit/mention' 7 | import { akColorN20, akColorN20A, akColorN60 } from '@atlaskit/util-shared-styles' 8 | import TextArea from 'react-textarea-autosize' 9 | import { assign } from 'lodash' 10 | 11 | @observer 12 | export default class CommentEditor extends Component { 13 | constructor (props) { 14 | super(props) 15 | this.model = props.commentEditor 16 | this.handleChange = this.handleChange.bind(this) 17 | this.handleKeyDown = this.handleKeyDown.bind(this) 18 | this.handleTextAreaHeightChange = this.handleTextAreaHeightChange.bind(this) 19 | this.handleInputRef = this.handleInputRef.bind(this) 20 | this.handleFocus = this.handleFocus.bind(this) 21 | this.handleBlur = this.handleBlur.bind(this) 22 | } 23 | render () { 24 | const { isPosting, text } = this.model 25 | const style = { 26 | border: 'none', 27 | font: 'inherit', 28 | background: 'inherit', 29 | padding: '6px 6px 4px 6px', 30 | width: '410px', 31 | height: '22px' 32 | } 33 | if (isPosting) { 34 | assign(style, { 35 | cursor: 'not-allowed', 36 | pointerEvents: 'none', 37 | background: akColorN20, 38 | border: akColorN20A, 39 | text: akColorN60 40 | }) 41 | } 42 | return ( 43 | 44 | 48 | 49 |