diff --git a/.babelrc b/.babelrc index 0a7b1c2f42..4f9a8c8d49 100644 --- a/.babelrc +++ b/.babelrc @@ -36,6 +36,13 @@ "babel-plugin-transform-require-ignore", { "extensions": [".scss", ".css"] } + ], + [ + "react-intl", + { + "enforceDescriptions": false, + "messagesDir": "./i18n/json" + } ] ] } diff --git a/src/api/APIFactory.js b/src/api/APIFactory.js index 0bce7b5155..0c74387937 100644 --- a/src/api/APIFactory.js +++ b/src/api/APIFactory.js @@ -12,6 +12,7 @@ import FileAPI from './File'; import WebLinkAPI from './WebLink'; import SearchAPI from './Search'; import RecentsAPI from './Recents'; +import VersionsAPI from './Versions'; import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD, TYPE_FOLDER, TYPE_FILE, TYPE_WEBLINK } from '../constants'; import type { Options, ItemType, ItemAPI } from '../flowTypes'; @@ -56,6 +57,11 @@ class APIFactory { */ recentsAPI: RecentsAPI; + /** + * @property {VersionsAPI} + */ + versionsAPI: VersionsAPI; + /** * [constructor] * @@ -111,6 +117,10 @@ class APIFactory { this.recentsAPI.destroy(); delete this.recentsAPI; } + if (this.versionsAPI) { + this.versionsAPI.destroy(); + delete this.versionsAPI; + } if (destroyCache) { this.options.cache = new Cache(); } @@ -228,6 +238,20 @@ class APIFactory { this.recentsAPI = new RecentsAPI(this.options); return this.recentsAPI; } + + /** + * API for versions + * + * @param {boolean} shouldDestroy - true if the factory should destroy before returning the call + * @return {VersionsAPI} VersionsAPI instance + */ + getVersionsAPI(shouldDestroy: boolean): VersionsAPI { + if (shouldDestroy) { + this.destroy(); + } + this.versionsAPI = new VersionsAPI(this.options); + return this.versionsAPI; + } } export default APIFactory; diff --git a/src/api/File.js b/src/api/File.js index 303ee0d1be..33ec5c222f 100644 --- a/src/api/File.js +++ b/src/api/File.js @@ -29,7 +29,7 @@ class File extends Item { * * @return {string} typed id for file */ - getTypedFileId(id: string): string { + static getTypedFileId(id: string): string { return `${TYPED_ID_FILE_PREFIX}${id}`; } @@ -93,7 +93,7 @@ class File extends Item { return this.xhr .put({ - id: this.getTypedFileId(id), + id: File.getTypedFileId(id), url: this.getUrl(id), data: { description } }) @@ -147,7 +147,7 @@ class File extends Item { // as thats what needed by preview. return this.xhr .get({ - id: this.getTypedFileId(id), + id: File.getTypedFileId(id), url: this.getUrl(id), params: { fields: getFieldsAsString(true, includePreviewSidebarFields) diff --git a/src/api/Versions.js b/src/api/Versions.js new file mode 100644 index 0000000000..b22896cd72 --- /dev/null +++ b/src/api/Versions.js @@ -0,0 +1,52 @@ +/** + * @flow + * @file Helper for the box versions API + * @author Box + */ + +import Base from './Base'; +import File from './File'; +import type { FileVersions } from '../flowTypes'; + +class Versions extends Base { + /** + * API URL for versions + * + * @param {string} [id] - a box file id + * @return {string} base url for files + */ + getUrl(id: string): string { + if (!id) { + throw new Error('Missing file id!'); + } + return `${this.getBaseApiUrl()}/files/${id}/versions`; + } + + /** + * Gets the versions for a box file + * + * @param {string} id - a box file id + * @param {Function} successCallback - Function to call with results + * @param {Function} errorCallback - Function to call with errors + * @return {Promise} + */ + versions(id: string, successCallback: Function, errorCallback: Function): Promise { + if (this.isDestroyed()) { + return Promise.reject(); + } + + // Make the XHR request + // We only need the total_count for now + return this.xhr + .get({ + id: File.getTypedFileId(id), + url: this.getUrl(id) + }) + .then(({ data }: { data: FileVersions }) => { + successCallback(data); + }) + .catch(errorCallback); + } +} + +export default Versions; diff --git a/src/api/__tests__/APIFactory-test.js b/src/api/__tests__/APIFactory-test.js index 6f0f122bb5..d3af3a8eb3 100644 --- a/src/api/__tests__/APIFactory-test.js +++ b/src/api/__tests__/APIFactory-test.js @@ -9,6 +9,7 @@ import FileAPI from '../File'; import WebLinkAPI from '../WebLink'; import SearchAPI from '../Search'; import RecentsAPI from '../Recents'; +import VersionsAPI from '../Versions'; import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD } from '../../constants'; let factory; @@ -33,6 +34,7 @@ describe('api/APIFactory', () => { factory.plainUploadAPI = { destroy: jest.fn() }; factory.chunkedUploadAPI = { destroy: jest.fn() }; factory.recentsAPI = { destroy: jest.fn() }; + factory.versionsAPI = { destroy: jest.fn() }; factory.destroy(); expect(factory.fileAPI).toBeUndefined(); expect(factory.folderAPI).toBeUndefined(); @@ -41,6 +43,7 @@ describe('api/APIFactory', () => { expect(factory.plainUploadAPI).toBeUndefined(); expect(factory.chunkedUploadAPI).toBeUndefined(); expect(factory.recentsAPI).toBeUndefined(); + expect(factory.versionsAPI).toBeUndefined(); }); test('should not destroy cache by default', () => { const { cache } = factory.options; @@ -156,4 +159,26 @@ describe('api/APIFactory', () => { expect(recentsAPI.options.uploadHost).toBe(DEFAULT_HOSTNAME_UPLOAD); }); }); + + describe('getVersionsAPI()', () => { + test('should call destroy and return versions API', () => { + const spy = jest.spyOn(factory, 'destroy'); + const versionsAPI = factory.getVersionsAPI(true); + expect(spy).toBeCalled(); + expect(versionsAPI).toBeInstanceOf(VersionsAPI); + expect(versionsAPI.options.cache).toBeInstanceOf(Cache); + expect(versionsAPI.options.apiHost).toBe(DEFAULT_HOSTNAME_API); + expect(versionsAPI.options.uploadHost).toBe(DEFAULT_HOSTNAME_UPLOAD); + }); + + test('should not call destroy and return versions API', () => { + const spy = jest.spyOn(factory, 'destroy'); + const versionsAPI = factory.getVersionsAPI(); + expect(spy).not.toHaveBeenCalled(); + expect(versionsAPI).toBeInstanceOf(VersionsAPI); + expect(versionsAPI.options.cache).toBeInstanceOf(Cache); + expect(versionsAPI.options.apiHost).toBe(DEFAULT_HOSTNAME_API); + expect(versionsAPI.options.uploadHost).toBe(DEFAULT_HOSTNAME_UPLOAD); + }); + }); }); diff --git a/src/api/__tests__/File-test.js b/src/api/__tests__/File-test.js index 79dd6f0c85..adbae629e7 100644 --- a/src/api/__tests__/File-test.js +++ b/src/api/__tests__/File-test.js @@ -18,12 +18,6 @@ describe('api/File', () => { }); }); - describe('getTypedFileId()', () => { - test('should return correct typed id', () => { - expect(file.getTypedFileId('foo')).toBe('file_foo'); - }); - }); - describe('getUrl()', () => { test('should return correct file api url without id', () => { expect(file.getUrl()).toBe('https://api.box.com/2.0/files'); @@ -94,7 +88,7 @@ describe('api/File', () => { }); test('should make an xhr', () => { - file.getTypedFileId = jest.fn().mockReturnValue('id'); + File.getTypedFileId = jest.fn().mockReturnValue('id'); file.getUrl = jest.fn().mockReturnValue('url'); file.merge = jest.fn(); @@ -123,6 +117,8 @@ describe('api/File', () => { test('should merge the new file description in and execute the success callback', () => { file.getCacheKey = jest.fn().mockReturnValue('key'); file.merge = jest.fn(); + File.getTypedFileId = jest.fn().mockReturnValue('file_id'); + const mockFile = { id: '1', permissions: { diff --git a/src/api/__tests__/Versions-test.js b/src/api/__tests__/Versions-test.js new file mode 100644 index 0000000000..ed2404f296 --- /dev/null +++ b/src/api/__tests__/Versions-test.js @@ -0,0 +1,71 @@ +import Versions from '../Versions'; + +let versions; +const versionsResponse = { total_count: 0, entries: [] }; + +describe('api/Versions', () => { + beforeEach(() => { + versions = new Versions({}); + }); + + describe('getUrl()', () => { + test('should throw when version api url without id', () => { + expect(() => { + versions.getUrl(); + }).toThrow(); + }); + test('should return correct version api url with id', () => { + expect(versions.getUrl('foo')).toBe('https://api.box.com/2.0/files/foo/versions'); + }); + }); + + describe('file()', () => { + test('should not do anything if destroyed', () => { + versions.isDestroyed = jest.fn().mockReturnValueOnce(true); + versions.xhr = null; + + const successCb = jest.fn(); + const errorCb = jest.fn(); + + return versions.versions('id', successCb, errorCb).catch(() => { + expect(successCb).not.toHaveBeenCalled(); + expect(errorCb).not.toHaveBeenCalled(); + }); + }); + + test('should make xhr to get versions and call success callback', () => { + versions.xhr = { + get: jest.fn().mockReturnValueOnce(Promise.resolve({ data: versionsResponse })) + }; + + const successCb = jest.fn(); + + return versions.versions('id', successCb).then(() => { + expect(successCb).toHaveBeenCalledWith(versionsResponse); + expect(versions.xhr.get).toHaveBeenCalledWith({ + id: 'file_id', + url: 'https://api.box.com/2.0/files/id/versions' + }); + }); + }); + + test('should call error callback when xhr fails', () => { + const error = new Error('error'); + versions.xhr = { + get: jest.fn().mockReturnValueOnce(Promise.reject(error)) + }; + + const successCb = jest.fn(); + const errorCb = jest.fn(); + + return versions.versions('id', successCb, errorCb).then(() => { + expect(successCb).not.toHaveBeenCalled(); + expect(errorCb).toHaveBeenCalledWith(error); + expect(versions.xhr.get).toHaveBeenCalledWith({ + id: 'file_id', + url: 'https://api.box.com/2.0/files/id/versions' + }); + }); + }); + }); +}); diff --git a/src/components/ContentSidebar/ContentSidebar.js b/src/components/ContentSidebar/ContentSidebar.js index 89f912a3f9..39f806acc8 100644 --- a/src/components/ContentSidebar/ContentSidebar.js +++ b/src/components/ContentSidebar/ContentSidebar.js @@ -15,7 +15,7 @@ import API from '../../api'; import Cache from '../../util/Cache'; import Internationalize from '../Internationalize'; import { DEFAULT_HOSTNAME_API, CLIENT_NAME_CONTENT_SIDEBAR } from '../../constants'; -import type { Token, BoxItem, StringMap } from '../../flowTypes'; +import type { Token, BoxItem, StringMap, FileVersions } from '../../flowTypes'; import '../fonts.scss'; import '../base.scss'; import '../modal.scss'; @@ -37,6 +37,7 @@ type Props = { hasAccessStats: boolean, hasClassification: boolean, hasActivityFeed: boolean, + hasVersions: boolean, language?: string, messages?: StringMap, cache?: Cache, @@ -44,11 +45,13 @@ type Props = { sharedLinkPassword?: string, requestInterceptor?: Function, responseInterceptor?: Function, - onInteraction: Function + onInteraction: Function, + onVersionHistoryClick?: Function }; type State = { - file?: BoxItem + file?: BoxItem, + versions?: FileVersions }; class ContentSidebar extends PureComponent { @@ -73,6 +76,7 @@ class ContentSidebar extends PureComponent { hasAccessStats: false, hasClassification: false, hasActivityFeed: false, + hasVersions: false, onInteraction: noop }; @@ -138,12 +142,15 @@ class ContentSidebar extends PureComponent { * @return {void} */ componentDidMount() { - const { fileId }: Props = this.props; + const { fileId, hasVersions }: Props = this.props; this.rootElement = ((document.getElementById(this.id): any): HTMLElement); this.appElement = ((this.rootElement.firstElementChild: any): HTMLElement); if (fileId) { this.fetchFile(fileId); + if (hasVersions) { + this.fetchVersions(fileId); + } } } @@ -155,16 +162,16 @@ class ContentSidebar extends PureComponent { */ componentWillReceiveProps(nextProps: Props): void { const { fileId, token }: Props = this.props; - const { fileId: newFileId }: Props = nextProps; + const { fileId: newFileId, token: newToken, hasVersions }: Props = nextProps; - const hasTokenChanged = nextProps.token !== token; + const hasTokenChanged = newToken !== token; const hasFileIdChanged = newFileId !== fileId; + const currentFileId = newFileId || fileId; - if (hasTokenChanged || hasFileIdChanged) { - if (newFileId) { - this.fetchFile(newFileId); - } else if (fileId) { - this.fetchFile(fileId); + if (currentFileId && (hasTokenChanged || hasFileIdChanged)) { + this.fetchFile(currentFileId); + if (hasVersions) { + this.fetchVersions(currentFileId); } } } @@ -185,16 +192,18 @@ class ContentSidebar extends PureComponent { hasNotices, hasAccessStats, hasClassification, - hasActivityFeed + hasActivityFeed, + hasVersions }: Props = this.props; return ( hasSkills || - hasProperties || - hasMetadata || - hasAccessStats || - hasClassification || - hasActivityFeed || + hasProperties || + hasMetadata || + hasAccessStats || + hasClassification || + hasActivityFeed || + hasVersions, hasNotices ); } @@ -290,6 +299,17 @@ class ContentSidebar extends PureComponent { this.setState({ file }); }; + /** + * File versions fetch success callback + * + * @private + * @param {Object} file - Box file + * @return {void} + */ + fetchVersionsSuccessCallback = (versions: FileVersions): void => { + this.setState({ versions }); + }; + /** * Fetches a file * @@ -304,6 +324,19 @@ class ContentSidebar extends PureComponent { } } + /** + * Fetches the versions for a file + * + * @private + * @param {string} id - File id + * @return {void} + */ + fetchVersions(id: string, shouldDestroy?: boolean = false): void { + if (this.shouldFetchOrRender()) { + this.api.getVersionsAPI(shouldDestroy).versions(id, this.fetchVersionsSuccessCallback, this.errorCallback); + } + } + /** * Renders the file preview * @@ -324,9 +357,11 @@ class ContentSidebar extends PureComponent { hasAccessStats, hasClassification, hasActivityFeed, - className + hasVersions, + className, + onVersionHistoryClick }: Props = this.props; - const { file }: State = this.state; + const { file, versions }: State = this.state; if (!this.shouldFetchOrRender()) { return null; @@ -339,6 +374,7 @@ class ContentSidebar extends PureComponent { {file ? ( { rootElement={this.rootElement} onInteraction={this.onInteraction} onDescriptionChange={this.onDescriptionChange} + onVersionHistoryClick={onVersionHistoryClick} + hasVersions={hasVersions} /> ) : (
diff --git a/src/components/ContentSidebar/DetailsSidebar.js b/src/components/ContentSidebar/DetailsSidebar.js index 41160cdcd2..aabb7129b8 100644 --- a/src/components/ContentSidebar/DetailsSidebar.js +++ b/src/components/ContentSidebar/DetailsSidebar.js @@ -13,8 +13,9 @@ import messages from '../messages'; import SidebarSection from './SidebarSection'; import SidebarContent from './SidebarContent'; import SidebarSkills from './Skills/SidebarSkills'; +import SidebarVersions from './SidebarVersions'; import SidebarNotices from './SidebarNotices'; -import type { BoxItem } from '../../flowTypes'; +import type { BoxItem, FileVersions } from '../../flowTypes'; import './DetailsSidebar.scss'; type Props = { @@ -27,11 +28,14 @@ type Props = { hasMetadata: boolean, hasAccessStats: boolean, hasClassification: boolean, + hasVersions: boolean, rootElement: HTMLElement, appElement: HTMLElement, onInteraction: Function, onDescriptionChange: Function, - intl: any + intl: any, + onVersionHistoryClick?: Function, + versions: FileVersions }; /* eslint-disable jsx-a11y/label-has-for */ @@ -45,10 +49,13 @@ const DetailsSidebar = ({ hasMetadata, hasAccessStats, hasClassification, + hasVersions, rootElement, appElement, onInteraction, onDescriptionChange, + onVersionHistoryClick, + versions, intl }: Props) => { if (!hasSkills && !hasProperties && !hasMetadata && !hasAccessStats && !hasClassification && !hasNotices) { @@ -59,6 +66,7 @@ const DetailsSidebar = ({ return ( }> + {hasVersions && } {hasNotices && } {hasSkills && ( { const shouldShowSkills = hasSkills && hasSkillsData(file); @@ -63,10 +69,13 @@ const Sidebar = ({ hasNotices={hasNotices} hasAccessStats={hasAccessStats} hasClassification={hasClassification} + hasVersions={hasVersions} appElement={appElement} rootElement={rootElement} onInteraction={onInteraction} onDescriptionChange={onDescriptionChange} + onVersionHistoryClick={onVersionHistoryClick} + versions={versions} /> ); diff --git a/src/components/ContentSidebar/SidebarVersions.js b/src/components/ContentSidebar/SidebarVersions.js new file mode 100644 index 0000000000..b64e5edfff --- /dev/null +++ b/src/components/ContentSidebar/SidebarVersions.js @@ -0,0 +1,36 @@ +/** + * @flow + * @file Versions sidebar component + * @author Box + */ + +import React from 'react'; +import VersionHistoryLink from 'box-react-ui/lib/features/item-details/VersionHistoryLink'; +import SidebarSection from './SidebarSection'; +import type { FileVersions } from '../../flowTypes'; + +type Props = { + onVersionHistoryClick?: Function, + versions: FileVersions +}; + +const SidebarVersions = ({ + onVersionHistoryClick, + versions = { + total_count: 0 + } +}: Props) => { + const { total_count } = versions; + + if (!total_count) { + return null; + } + + return ( + + + + ); +}; + +export default SidebarVersions; diff --git a/src/components/ContentSidebar/__tests__/SidebarVersions-test.js b/src/components/ContentSidebar/__tests__/SidebarVersions-test.js new file mode 100644 index 0000000000..c51c579190 --- /dev/null +++ b/src/components/ContentSidebar/__tests__/SidebarVersions-test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import VersionHistoryLink from 'box-react-ui/lib/features/item-details/VersionHistoryLink'; +import SidebarVersions from '../SidebarVersions'; + +describe('components/ContentSidebar/SidebarVersions', () => { + const getWrapper = (props) => shallow(); + + test('should render the versions when total_count > 0', () => { + const props = { + versions: { + total_count: 1 + } + }; + const wrapper = getWrapper(props); + + expect(wrapper.find(VersionHistoryLink)).toHaveLength(1); + expect(wrapper).toMatchSnapshot(); + }); + + test('should not render the versions when total_count is falsy', () => { + const props = { + versions: { + total_count: 0 + } + }; + const wrapper = getWrapper(props); + + expect(wrapper.find(VersionHistoryLink)).toHaveLength(0); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/components/ContentSidebar/__tests__/__snapshots__/SidebarVersions-test.js.snap b/src/components/ContentSidebar/__tests__/__snapshots__/SidebarVersions-test.js.snap new file mode 100644 index 0000000000..dec46b08f5 --- /dev/null +++ b/src/components/ContentSidebar/__tests__/__snapshots__/SidebarVersions-test.js.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/ContentSidebar/SidebarVersions should not render the versions when total_count is falsy 1`] = ` +ShallowWrapper { + "length": 1, + Symbol(enzyme.__root__): [Circular], + Symbol(enzyme.__unrendered__): , + Symbol(enzyme.__renderer__): Object { + "batchedUpdates": [Function], + "getNode": [Function], + "render": [Function], + "simulateEvent": [Function], + "unmount": [Function], + }, + Symbol(enzyme.__node__): null, + Symbol(enzyme.__nodes__): Array [ + null, + ], + Symbol(enzyme.__options__): Object { + "adapter": ReactSixteenAdapter { + "options": Object { + "enableComponentDidUpdateOnSetState": true, + }, + }, + }, +} +`; + +exports[`components/ContentSidebar/SidebarVersions should render the versions when total_count > 0 1`] = ` +ShallowWrapper { + "length": 1, + Symbol(enzyme.__root__): [Circular], + Symbol(enzyme.__unrendered__): , + Symbol(enzyme.__renderer__): Object { + "batchedUpdates": [Function], + "getNode": [Function], + "render": [Function], + "simulateEvent": [Function], + "unmount": [Function], + }, + Symbol(enzyme.__node__): Object { + "instance": null, + "key": undefined, + "nodeType": "class", + "props": Object { + "children": , + "className": "", + "isOpen": true, + }, + "ref": null, + "rendered": Object { + "instance": null, + "key": undefined, + "nodeType": "function", + "props": Object { + "className": "", + "onClick": undefined, + "versionCount": 2, + }, + "ref": null, + "rendered": null, + "type": [Function], + }, + "type": [Function], + }, + Symbol(enzyme.__nodes__): Array [ + Object { + "instance": null, + "key": undefined, + "nodeType": "class", + "props": Object { + "children": , + "className": "", + "isOpen": true, + }, + "ref": null, + "rendered": Object { + "instance": null, + "key": undefined, + "nodeType": "function", + "props": Object { + "className": "", + "onClick": undefined, + "versionCount": 2, + }, + "ref": null, + "rendered": null, + "type": [Function], + }, + "type": [Function], + }, + ], + Symbol(enzyme.__options__): Object { + "adapter": ReactSixteenAdapter { + "options": Object { + "enableComponentDidUpdateOnSetState": true, + }, + }, + }, +} +`; diff --git a/src/components/base.scss b/src/components/base.scss index 981f271d6f..4fac86c953 100644 --- a/src/components/base.scss +++ b/src/components/base.scss @@ -175,7 +175,6 @@ .btn-plain { background: none; border: 0 none; - color: inherit; cursor: pointer; font-size: 13px; line-height: 16px; diff --git a/src/flowTypes.js b/src/flowTypes.js index 79b0f7ddba..73e66efcce 100644 --- a/src/flowTypes.js +++ b/src/flowTypes.js @@ -151,7 +151,14 @@ export type MetadataType = { }; export type BoxItemVersion = { - id?: string + id: string, + type: string, + sha1: string, + name?: string, + size?: number, + created_at?: string, + modified_at?: string, + modified_by?: User }; export type BoxItem = { @@ -338,3 +345,8 @@ export type MultiputPart = { export type MultiputData = { part?: MultiputPart }; + +export type FileVersions = { + total_count: number, + entries?: Array +}; diff --git a/test/sidebar.html b/test/sidebar.html index dffccf8207..6b8fbb7ecc 100644 --- a/test/sidebar.html +++ b/test/sidebar.html @@ -74,7 +74,11 @@ sidebar1.show(fileId, token, { container: '.sidebar1', hasProperties: true, - hasNotices: true + hasVersions: true, + hasNotices: true, + onVersionHistoryClick : function(){ + alert('hello, world!'); + } }); const sidebar2 = new ContentSidebar(); @@ -83,6 +87,7 @@ hasProperties: true, hasSkills: true, hasTitle: true, + hasVersions: true, hasNotices: true });