From 8249f4e17e573e02ac862be787be1737e260e738 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 27 Apr 2018 11:22:20 +0100 Subject: [PATCH 001/135] Wrap copies of existing history UI elements in components. --- .../web/app/views/project/editor/history.pug | 83 +------------ .../project/editor/history/entriesListV1.pug | 82 +++++++++++++ .../project/editor/history/entriesListV2.pug | 94 +++++++++++++++ .../coffee/ide/history/HistoryManager.coffee | 1 + .../ide/history/HistoryV2Manager.coffee | 2 + .../components/historyEntriesList.coffee | 17 +++ .../history/components/historyEntry.coffee | 15 +++ .../HistoryV2ListController.coffee | 110 ++++++++++++++++++ 8 files changed, 323 insertions(+), 81 deletions(-) create mode 100644 services/web/app/views/project/editor/history/entriesListV1.pug create mode 100644 services/web/app/views/project/editor/history/entriesListV2.pug create mode 100644 services/web/public/coffee/ide/history/components/historyEntriesList.coffee create mode 100644 services/web/public/coffee/ide/history/components/historyEntry.coffee create mode 100644 services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index d366d5f41a..f237aff89e 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -40,87 +40,8 @@ div#history(ng-show="ui.view == 'history'") p a.small(href, ng-click="toggleHistory()") #{translate("cancel")} - aside.change-list( - ng-controller="HistoryListController" - infinite-scroll="loadMore()" - infinite-scroll-disabled="history.loading || history.atEnd" - infinite-scroll-initialize="ui.view == 'history'" - ) - .infinite-scroll-inner - ul.list-unstyled( - ng-class="{\ - 'hover-state': history.hoveringOverListSelectors\ - }" - ) - li.change( - ng-repeat="update in history.updates" - ng-class="{\ - 'first-in-day': update.meta.first_in_day,\ - 'selected': update.inSelection,\ - 'selected-to': update.selectedTo,\ - 'selected-from': update.selectedFrom,\ - 'hover-selected': update.inHoverSelection,\ - 'hover-selected-to': update.hoverSelectedTo,\ - 'hover-selected-from': update.hoverSelectedFrom,\ - }" - ng-controller="HistoryListItemController" - ) - - div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} - - div.selectors - div.range - form - input.selector-from( - type="radio" - name="fromVersion" - ng-model="update.selectedFrom" - ng-value="true" - ng-mouseover="mouseOverSelectedFrom()" - ng-mouseout="mouseOutSelectedFrom()" - ng-show="update.afterSelection || update.inSelection" - ) - form - input.selector-to( - type="radio" - name="toVersion" - ng-model="update.selectedTo" - ng-value="true" - ng-mouseover="mouseOverSelectedTo()" - ng-mouseout="mouseOutSelectedTo()" - ng-show="update.beforeSelection || update.inSelection" - ) - - div.description(ng-click="select()") - div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} - div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") - | Edited - div.docs(ng-repeat="pathname in update.pathnames") - .doc {{ pathname }} - div.docs(ng-repeat="project_op in update.project_ops") - div(ng-if="project_op.rename") - .action Renamed - .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} - div(ng-if="project_op.add") - .action Created - .doc {{ project_op.add.pathname }} - div(ng-if="project_op.remove") - .action Deleted - .doc {{ project_op.remove.pathname }} - div.users - div.user(ng-repeat="update_user in update.meta.users") - .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") - .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") - .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") - .name(ng-if="update_user && update_user.id == user.id") You - .name(ng-if="update_user == null") #{translate("anonymous")} - div.user(ng-if="update.meta.users.length == 0") - .color-square(style="background-color: hsl(100, 100%, 50%)") - span #{translate("anonymous")} - - .loading(ng-show="history.loading") - i.fa.fa-spin.fa-refresh - |    #{translate("loading")}... + include ./history/entriesListV1 + include ./history/entriesListV2 include ./history/diffPanelV1 include ./history/diffPanelV2 diff --git a/services/web/app/views/project/editor/history/entriesListV1.pug b/services/web/app/views/project/editor/history/entriesListV1.pug new file mode 100644 index 0000000000..2db6b9e533 --- /dev/null +++ b/services/web/app/views/project/editor/history/entriesListV1.pug @@ -0,0 +1,82 @@ +aside.change-list( + ng-if="!history.isV2" + ng-controller="HistoryListController" + infinite-scroll="loadMore()" + infinite-scroll-disabled="history.loading || history.atEnd" + infinite-scroll-initialize="ui.view == 'history'" + ) + .infinite-scroll-inner + ul.list-unstyled( + ng-class="{\ + 'hover-state': history.hoveringOverListSelectors\ + }" + ) + li.change( + ng-repeat="update in history.updates" + ng-class="{\ + 'first-in-day': update.meta.first_in_day,\ + 'selected': update.inSelection,\ + 'selected-to': update.selectedTo,\ + 'selected-from': update.selectedFrom,\ + 'hover-selected': update.inHoverSelection,\ + 'hover-selected-to': update.hoverSelectedTo,\ + 'hover-selected-from': update.hoverSelectedFrom,\ + }" + ng-controller="HistoryListItemController" + ) + + div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} + + div.selectors + div.range + form + input.selector-from( + type="radio" + name="fromVersion" + ng-model="update.selectedFrom" + ng-value="true" + ng-mouseover="mouseOverSelectedFrom()" + ng-mouseout="mouseOutSelectedFrom()" + ng-show="update.afterSelection || update.inSelection" + ) + form + input.selector-to( + type="radio" + name="toVersion" + ng-model="update.selectedTo" + ng-value="true" + ng-mouseover="mouseOverSelectedTo()" + ng-mouseout="mouseOutSelectedTo()" + ng-show="update.beforeSelection || update.inSelection" + ) + + div.description(ng-click="select()") + div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} + div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") + | Edited + div.docs(ng-repeat="pathname in update.pathnames") + .doc {{ pathname }} + div.docs(ng-repeat="project_op in update.project_ops") + div(ng-if="project_op.rename") + .action Renamed + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action Created + .doc {{ project_op.add.pathname }} + div(ng-if="project_op.remove") + .action Deleted + .doc {{ project_op.remove.pathname }} + div.users + div.user(ng-repeat="update_user in update.meta.users") + .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") + .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") + .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") + .name(ng-if="update_user && update_user.id == user.id") You + .name(ng-if="update_user == null") #{translate("anonymous")} + div.user(ng-if="update.meta.users.length == 0") + .color-square(style="background-color: hsl(100, 100%, 50%)") + span #{translate("anonymous")} + + .loading(ng-show="history.loading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug new file mode 100644 index 0000000000..e5bc339c51 --- /dev/null +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -0,0 +1,94 @@ +aside.change-list( + ng-if="history.isV2" + ng-controller="HistoryV2ListController" +) + history-entries-list( + entries="history.updates" + load-entries="loadMore()" + load-disabled="history.loading || history.atEnd" + load-initialize="ui.view == 'history'" + is-loading="history.loading" + ) + + +script(type="text/ng-template", id="historyEntriesListTpl") + div( + infinite-scroll="$ctrl.loadEntries()" + infinite-scroll-disabled="$ctrl.loadDisabled" + infinite-scroll-initialize="$ctrl.loadInitialize" + ) + .infinite-scroll-inner + history-entry( + ng-repeat="entry in $ctrl.entries" + entry="entry" + ) + .loading(ng-show="history.loading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... + +script(type="text/ng-template", id="historyEntryTpl") + .change( + ng-class="{\ + 'first-in-day': $ctrl.entry.meta.first_in_day,\ + 'selected': $ctrl.entry.inSelection,\ + 'selected-to': $ctrl.entry.selectedTo,\ + 'selected-from': $ctrl.entry.selectedFrom,\ + 'hover-selected': $ctrl.entry.inHoverSelection,\ + 'hover-selected-to': $ctrl.entry.hoverSelectedTo,\ + 'hover-selected-from': $ctrl.entry.hoverSelectedFrom,\ + }" + ) + + div.day(ng-show="$ctrl.entry.meta.first_in_day") {{ $ctrl.entry.meta.end_ts | relativeDate }} + + //- div.selectors + //- div.range + //- form + //- input.selector-from( + //- type="radio" + //- name="fromVersion" + //- ng-model="$ctrl.entry.selectedFrom" + //- ng-value="true" + //- ng-mouseover="mouseOverSelectedFrom()" + //- ng-mouseout="mouseOutSelectedFrom()" + //- ng-show="$ctrl.entry.afterSelection || $ctrl.entry.inSelection" + //- ) + //- form + //- input.selector-to( + //- type="radio" + //- name="toVersion" + //- ng-model="$ctrl.entry.selectedTo" + //- ng-value="true" + //- ng-mouseover="mouseOverSelectedTo()" + //- ng-mouseout="mouseOutSelectedTo()" + //- ng-show="$ctrl.entry.beforeSelection || $ctrl.entry.inSelection" + ) + + div.description(ng-click="select()") + div.time {{ $ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} + div.action.action-edited(ng-if="history.isV2 && $ctrl.entry.pathnames.length > 0") + | Edited + div.docs(ng-repeat="pathname in $ctrl.entry.pathnames") + div + .action Edited + .doc {{ pathname }} + div.docs(ng-repeat="project_op in $ctrl.entry.project_ops") + div(ng-if="project_op.rename") + .action Renamed + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action Created + .doc {{ project_op.add.pathname }} + div(ng-if="project_op.remove") + .action Deleted + .doc {{ project_op.remove.pathname }} + div.users + div.user(ng-repeat="update_user in $ctrl.entry.meta.users") + .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") + .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") + .name(ng-if="update_user && update_user.id != user.id" ng-bind="$ctrl.displayName(update_user)") + .name(ng-if="update_user && update_user.id == user.id") You + .name(ng-if="update_user == null") #{translate("anonymous")} + div.user(ng-if="$ctrl.entry.meta.users.length == 0") + .color-square(style="background-color: hsl(100, 100%, 50%)") + span #{translate("anonymous")} \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index cdc39ffd05..e79f0ad116 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -3,6 +3,7 @@ define [ "ide/colors/ColorManager" "ide/history/util/displayNameForUser" "ide/history/controllers/HistoryListController" + "ide/history/controllers/HistoryV2ListController" "ide/history/controllers/HistoryDiffController" "ide/history/controllers/HistoryV2DiffController" "ide/history/directives/infiniteScroll" diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 72f4c79bdd..c2c75a0373 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -5,6 +5,8 @@ define [ "ide/history/controllers/HistoryListController" "ide/history/controllers/HistoryDiffController" "ide/history/directives/infiniteScroll" + "ide/history/components/historyEntriesList" + "ide/history/components/historyEntry" ], (moment, ColorManager, displayNameForUser) -> class HistoryManager constructor: (@ide, @$scope) -> diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee new file mode 100644 index 0000000000..11ee4e73de --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -0,0 +1,17 @@ +define [ + "base" +], (App) -> + historyEntriesListController = ($scope, $element, $attrs) -> + ctrl = @ + return + + App.component "historyEntriesList", { + bindings: + entries: "<" + loadEntries: "&" + loadDisabled: "<" + loadInitialize: "<" + isLoading: "<" + controller: historyEntriesListController + templateUrl: "historyEntriesListTpl" + } diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee new file mode 100644 index 0000000000..fbfd4dab08 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -0,0 +1,15 @@ +define [ + "base" + "ide/history/util/displayNameForUser" +], (App, displayNameForUser) -> + historyEntryController = ($scope, $element, $attrs) -> + ctrl = @ + ctrl.displayName = displayNameForUser + return + + App.component "historyEntry", { + bindings: + entry: "<" + controller: historyEntryController + templateUrl: "historyEntryTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee new file mode 100644 index 0000000000..772f360a80 --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -0,0 +1,110 @@ +define [ + "base", + "ide/history/util/displayNameForUser" +], (App, displayNameForUser) -> + + App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) -> + $scope.hoveringOverListSelectors = false + + $scope.loadMore = () => + ide.historyManager.fetchNextBatchOfUpdates() + + $scope.recalculateSelectedUpdates = () -> + beforeSelection = true + afterSelection = false + $scope.history.selection.updates = [] + for update in $scope.history.updates + if update.selectedTo + inSelection = true + beforeSelection = false + + update.beforeSelection = beforeSelection + update.inSelection = inSelection + update.afterSelection = afterSelection + + if inSelection + $scope.history.selection.updates.push update + + if update.selectedFrom + inSelection = false + afterSelection = true + + $scope.recalculateHoveredUpdates = () -> + hoverSelectedFrom = false + hoverSelectedTo = false + for update in $scope.history.updates + # Figure out whether the to or from selector is hovered over + if update.hoverSelectedFrom + hoverSelectedFrom = true + if update.hoverSelectedTo + hoverSelectedTo = true + + if hoverSelectedFrom + # We want to 'hover select' everything between hoverSelectedFrom and selectedTo + inHoverSelection = false + for update in $scope.history.updates + if update.selectedTo + update.hoverSelectedTo = true + inHoverSelection = true + update.inHoverSelection = inHoverSelection + if update.hoverSelectedFrom + inHoverSelection = false + if hoverSelectedTo + # We want to 'hover select' everything between hoverSelectedTo and selectedFrom + inHoverSelection = false + for update in $scope.history.updates + if update.hoverSelectedTo + inHoverSelection = true + update.inHoverSelection = inHoverSelection + if update.selectedFrom + update.hoverSelectedFrom = true + inHoverSelection = false + + $scope.resetHoverState = () -> + for update in $scope.history.updates + delete update.hoverSelectedFrom + delete update.hoverSelectedTo + delete update.inHoverSelection + + $scope.$watch "history.updates.length", () -> + $scope.recalculateSelectedUpdates() + ] + + App.controller "HistoryListItemController", ["$scope", "event_tracking", ($scope, event_tracking) -> + $scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) -> + if selectedFrom + for update in $scope.history.updates + update.selectedFrom = false unless update == $scope.update + $scope.recalculateSelectedUpdates() + + $scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) -> + if selectedTo + for update in $scope.history.updates + update.selectedTo = false unless update == $scope.update + $scope.recalculateSelectedUpdates() + + $scope.select = () -> + event_tracking.sendMB "history-view-change" + $scope.update.selectedTo = true + $scope.update.selectedFrom = true + + $scope.mouseOverSelectedFrom = () -> + $scope.history.hoveringOverListSelectors = true + $scope.update.hoverSelectedFrom = true + $scope.recalculateHoveredUpdates() + + $scope.mouseOutSelectedFrom = () -> + $scope.history.hoveringOverListSelectors = false + $scope.resetHoverState() + + $scope.mouseOverSelectedTo = () -> + $scope.history.hoveringOverListSelectors = true + $scope.update.hoverSelectedTo = true + $scope.recalculateHoveredUpdates() + + $scope.mouseOutSelectedTo = () -> + $scope.history.hoveringOverListSelectors = false + $scope.resetHoverState() + + $scope.displayName = displayNameForUser + ] From 6e6bc91130caecbf29717c29c801a74a14c1ccd5 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 27 Apr 2018 15:59:28 +0100 Subject: [PATCH 002/135] Style the history entries components. --- .../project/editor/history/entriesListV2.pug | 69 ++-- .../components/historyEntriesList.coffee | 1 + .../history/components/historyEntry.coffee | 9 + .../stylesheets/_ol_style_includes.less | 2 +- .../public/stylesheets/_style_includes.less | 1 + .../stylesheets/app/editor/history-v2.less | 384 ++++++++++++++++++ .../stylesheets/core/_common-variables.less | 9 +- .../public/stylesheets/core/ol-variables.less | 8 + 8 files changed, 450 insertions(+), 33 deletions(-) create mode 100644 services/web/public/stylesheets/app/editor/history-v2.less diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index e5bc339c51..a19273bfb9 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -4,6 +4,7 @@ aside.change-list( ) history-entries-list( entries="history.updates" + current-user="user" load-entries="loadMore()" load-disabled="history.loading || history.atEnd" load-initialize="ui.view == 'history'" @@ -12,7 +13,7 @@ aside.change-list( script(type="text/ng-template", id="historyEntriesListTpl") - div( + .history-entries( infinite-scroll="$ctrl.loadEntries()" infinite-scroll-disabled="$ctrl.loadDisabled" infinite-scroll-initialize="$ctrl.loadInitialize" @@ -21,13 +22,14 @@ script(type="text/ng-template", id="historyEntriesListTpl") history-entry( ng-repeat="entry in $ctrl.entries" entry="entry" + current-user="$ctrl.currentUser" ) .loading(ng-show="history.loading") i.fa.fa-spin.fa-refresh |    #{translate("loading")}... script(type="text/ng-template", id="historyEntryTpl") - .change( + .history-entry( ng-class="{\ 'first-in-day': $ctrl.entry.meta.first_in_day,\ 'selected': $ctrl.entry.inSelection,\ @@ -39,7 +41,7 @@ script(type="text/ng-template", id="historyEntryTpl") }" ) - div.day(ng-show="$ctrl.entry.meta.first_in_day") {{ $ctrl.entry.meta.end_ts | relativeDate }} + time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }} //- div.selectors //- div.range @@ -64,31 +66,36 @@ script(type="text/ng-template", id="historyEntryTpl") //- ng-show="$ctrl.entry.beforeSelection || $ctrl.entry.inSelection" ) - div.description(ng-click="select()") - div.time {{ $ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} - div.action.action-edited(ng-if="history.isV2 && $ctrl.entry.pathnames.length > 0") - | Edited - div.docs(ng-repeat="pathname in $ctrl.entry.pathnames") - div - .action Edited - .doc {{ pathname }} - div.docs(ng-repeat="project_op in $ctrl.entry.project_ops") - div(ng-if="project_op.rename") - .action Renamed - .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} - div(ng-if="project_op.add") - .action Created - .doc {{ project_op.add.pathname }} - div(ng-if="project_op.remove") - .action Deleted - .doc {{ project_op.remove.pathname }} - div.users - div.user(ng-repeat="update_user in $ctrl.entry.meta.users") - .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") - .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") - .name(ng-if="update_user && update_user.id != user.id" ng-bind="$ctrl.displayName(update_user)") - .name(ng-if="update_user && update_user.id == user.id") You - .name(ng-if="update_user == null") #{translate("anonymous")} - div.user(ng-if="$ctrl.entry.meta.users.length == 0") - .color-square(style="background-color: hsl(100, 100%, 50%)") - span #{translate("anonymous")} \ No newline at end of file + .history-entry-details(ng-click="select()") + ol.history-entry-changes + li.history-entry-change( + ng-repeat="pathname in ::$ctrl.entry.pathnames" + ) + span.history-entry-change-action Edited + span.history-entry-change-doc {{ ::pathname }} + li.history-entry-change( + ng-repeat="project_op in ::$ctrl.entry.project_ops" + ) + span.history-entry-change-action {{ ::$ctrl.getProjectOpAction(project_op) }} + span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} + .history-entry-metadata + time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} + span  •  + ol.history-entry-metadata-users + li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users") + span.name( + ng-if="::update_user && update_user.id != $ctrl.currentUser.id" + ng-style="::{'color': 'hsl({{ update_user.hue }}, 70%, 50%)'}" + ) {{ ::$ctrl.displayName(update_user) }} + span.name( + ng-if="::update_user && update_user.id == $ctrl.currentUser.id" + ng-style="::{'color': 'hsl({{ update_user.hue }}, 70%, 50%)'}" + ) You + span.name( + ng-if="::update_user == null" + ng-style="::{'color': 'hsl(100, 70%, 50%)'}" + ) #{translate("anonymous")} + li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0") + span.name( + ng-style="::{'color': 'hsl(100, 70%, 50%)'}" + ) #{translate("anonymous")} \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee index 11ee4e73de..8eabf38ebc 100644 --- a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -12,6 +12,7 @@ define [ loadDisabled: "<" loadInitialize: "<" isLoading: "<" + currentUser: "<" controller: historyEntriesListController templateUrl: "historyEntriesListTpl" } diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee index fbfd4dab08..dbc63a105b 100644 --- a/services/web/public/coffee/ide/history/components/historyEntry.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -5,11 +5,20 @@ define [ historyEntryController = ($scope, $element, $attrs) -> ctrl = @ ctrl.displayName = displayNameForUser + ctrl.getProjectOpAction = (projectOp) -> + if projectOp.rename? then "Renamed" + else if projectOp.add? then "Created" + else if projectOp.remove? then "Deleted" + ctrl.getProjectOpDoc = (projectOp) -> + if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }" + else if projectOp.add? then "#{ projectOp.add.pathname}" + else if projectOp.remove? then "#{ projectOp.remove.pathname}" return App.component "historyEntry", { bindings: entry: "<" + currentUser: "<" controller: historyEntryController templateUrl: "historyEntryTpl" } \ No newline at end of file diff --git a/services/web/public/stylesheets/_ol_style_includes.less b/services/web/public/stylesheets/_ol_style_includes.less index 921b20f727..cc03279a73 100644 --- a/services/web/public/stylesheets/_ol_style_includes.less +++ b/services/web/public/stylesheets/_ol_style_includes.less @@ -1,2 +1,2 @@ @import "app/sidebar-v2-dash-pane.less"; -@import "app/front-chat-widget.less"; +@import "app/front-chat-widget.less"; \ No newline at end of file diff --git a/services/web/public/stylesheets/_style_includes.less b/services/web/public/stylesheets/_style_includes.less index 7f6070e69c..39ba48d94a 100644 --- a/services/web/public/stylesheets/_style_includes.less +++ b/services/web/public/stylesheets/_style_includes.less @@ -79,6 +79,7 @@ @import "app/review-features-page.less"; @import "app/error-pages.less"; @import "app/v1-badge.less"; +@import "app/editor/history-v2.less"; // Vendor CSS @import "../js/libs/pdfListView/TextLayer.css"; diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less new file mode 100644 index 0000000000..426ac70232 --- /dev/null +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -0,0 +1,384 @@ +.history-entries { + font-size: @history-base-font-size; + color: @history-base-color; + height: 100%; + background-color: @history-base-bg; +} + +.history-entry-day { + display: block; + background-color: @history-entry-day-bg; + color: #FFF; + padding: 5px 10px; + line-height: 1; +} + +.history-entry-details { + background-color: #FFF; + margin-bottom: 2px; + padding: 5px 10px; +} + .history-entry-changes { + .list-unstyled; + margin-bottom: 3px; + } + .history-entry-change { + display: flex; + } + .history-entry-change-action { + margin-right: 0.5em; + } + + .history-entry-change-doc { + color: @history-highlight-color; + font-weight: bold; + word-break: break-all; + } + .history-entry-metadata { + + } + .history-entry-metadata-time { + + } + + .history-entry-metadata-users { + display: inline; + padding: 0; + } + .history-entry-metadata-user { + display: inline; + &::after { + content: ', '; + } + &:last-of-type::after { + content: none; + } + } + +// @changesListWidth: 250px; +// @changesListPadding: @line-height-computed / 2; + +// @selector-padding-vertical: 10px; +// @selector-padding-horizontal: @line-height-computed / 2; +// @day-header-height: 24px; + +// @range-bar-color: @link-color; +// @range-bar-selected-offset: 14px; + +// #history { +// .upgrade-prompt { +// position: absolute; +// top: 0; +// bottom: 0; +// left: 0; +// right: 0; +// z-index: 100; +// background-color: rgba(128,128,128,0.4); +// .message { +// margin: auto; +// margin-top: 100px; +// padding: (@line-height-computed / 2) @line-height-computed; +// width: 400px; +// background-color: white; +// border-radius: 8px; +// } +// .message-wider { +// width: 650px; +// margin-top: 60px; +// padding: 0; +// } + +// .message-header { +// .modal-header; +// } + +// .message-body { +// .modal-body; +// } +// } + +// .diff-panel { +// .full-size; +// margin-right: @changesListWidth; +// } + +// .diff { +// .full-size; +// .toolbar { +// padding: 3px; +// .name { +// float: left; +// padding: 3px @line-height-computed / 4; +// display: inline-block; +// } +// } +// .diff-editor { +// .full-size; +// top: 40px; +// } +// .hide-ace-cursor { +// .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { +// display: none; +// } +// } +// .diff-deleted { +// padding: @line-height-computed; +// } +// .deleted-warning { +// background-color: @brand-danger; +// color: white; +// padding: @line-height-computed / 2; +// margin-right: @line-height-computed / 4; +// } +// &-binary { +// .alert { +// margin: @line-height-computed / 2; +// } +// } +// } + +// aside.change-list { +// border-left: 1px solid @editor-border-color; +// height: 100%; +// width: @changesListWidth; +// position: absolute; +// right: 0; + +// .loading { +// text-align: center; +// font-family: @font-family-serif; +// } + +// ul { +// li.change { +// position: relative; +// user-select: none; +// -ms-user-select: none; +// -moz-user-select: none; +// -webkit-user-select: none; + +// .day { +// background-color: #fafafa; +// border-bottom: 1px solid @editor-border-color; +// padding: 4px; +// font-weight: bold; +// text-align: center; +// height: @day-header-height; +// font-size: 14px; +// line-height: 1; +// } +// .selectors { +// input { +// margin: 0; +// } +// position: absolute; +// left: @selector-padding-horizontal; +// top: 0; +// bottom: 0; +// width: 24px; +// .selector-from { +// position: absolute; +// bottom: @selector-padding-vertical; +// left: 0; +// opacity: 0.8; +// } +// .selector-to { +// position: absolute; +// top: @selector-padding-vertical; +// left: 0; +// opacity: 0.8; +// } +// .range { +// position: absolute; +// left: 5px; +// width: 4px; +// top: 0; +// bottom: 0; +// } +// } +// .description { +// padding: (@line-height-computed / 4); +// padding-left: 38px; +// min-height: 38px; +// border-bottom: 1px solid @editor-border-color; +// cursor: pointer; +// &:hover { +// background-color: @gray-lightest; +// } +// } +// .users { +// .user { +// font-size: 0.8rem; +// color: @gray; +// text-transform: capitalize; +// position: relative; +// padding-left: 16px; +// .color-square { +// height: 12px; +// width: 12px; +// border-radius: 3px; +// position: absolute; +// left: 0; +// bottom: 3px; +// } +// .name { +// width: 94%; +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// } +// } +// } +// .time { +// float: right; +// color: @gray; +// display: inline-block; +// padding-right: (@line-height-computed / 2); +// font-size: 0.8rem; +// line-height: @line-height-computed; +// } +// .doc { +// font-size: 0.9rem; +// font-weight: bold; +// } +// .action { +// color: @gray; +// text-transform: uppercase; +// font-size: 0.7em; +// margin-bottom: -2px; +// margin-top: 2px; +// &-edited { +// margin-top: 0; +// } +// } +// } +// li.loading-changes, li.empty-message { +// padding: 6px; +// cursor: default; +// &:hover { +// background-color: inherit; +// } +// } +// li.selected { +// border-left: 4px solid @range-bar-color; +// .day { +// padding-left: 0; +// } +// .description { +// padding-left: 34px; +// } +// .selectors { +// left: @selector-padding-horizontal - 4px; +// .range { +// background-color: @range-bar-color; +// } +// } +// } +// li.selected-to { +// .selectors { +// .range { +// top: @range-bar-selected-offset; +// } +// .selector-to { +// opacity: 1; +// } +// } +// } +// li.selected-from { +// .selectors { +// .range { +// bottom: @range-bar-selected-offset; +// } +// .selector-from { +// opacity: 1; +// } +// } +// } +// li.first-in-day { +// .selectors { +// .selector-to { +// top: @day-header-height + @selector-padding-vertical; +// } +// } +// } +// li.first-in-day.selected-to { +// .selectors { +// .range { +// top: @day-header-height + @range-bar-selected-offset; +// } +// } +// } +// } +// ul.hover-state { +// li { +// .selectors { +// .range { +// background-color: transparent; +// top: 0; +// bottom: 0; +// } +// } +// } +// li.hover-selected { +// .selectors { +// .range { +// top: 0; +// background-color: @gray-light; +// } +// } +// } +// li.hover-selected-to { +// .selectors { +// .range { +// top: @range-bar-selected-offset; +// } +// .selector-to { +// opacity: 1; +// } +// } +// } +// li.hover-selected-from { +// .selectors { +// .range { +// bottom: @range-bar-selected-offset; +// } +// .selector-from { +// opacity: 1; +// } +// } +// } +// li.first-in-day.hover-selected-to { +// .selectors { +// .range { +// top: @day-header-height + @range-bar-selected-offset; +// } +// } +// } +// } +// } +// } + +// .diff-deleted { +// padding-top: 15px; +// } + +// .editor-dark { +// #history { +// aside.change-list { +// border-color: @editor-dark-toolbar-border-color; + +// ul li.change { +// .day { +// background-color: darken(@editor-dark-background-color, 10%); +// border-bottom: 1px solid @editor-dark-toolbar-border-color; +// } +// .description { +// border-bottom: 1px solid @editor-dark-toolbar-border-color; +// &:hover { +// background-color: black; +// } +// } +// } +// } +// } +// } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 9cb074c754..3c1f6b34b2 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -972,4 +972,11 @@ // System messages @sys-msg-background : @state-warning-bg; @sys-msg-color : #333; -@sys-msg-border : 1px solid @common-border-color; \ No newline at end of file +@sys-msg-border : 1px solid @common-border-color; + +// v2 History +@history-base-font-size : @font-size-small; +@history-base-bg : @gray-lightest; +@history-entry-day-bg : @gray-dark; +@history-base-color : @gray-light; +@history-highlight-color : @gray; \ No newline at end of file diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index e9125e0143..76b89d9fb5 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -265,6 +265,14 @@ @log-line-no-color : #FFF; @log-hints-color : @ol-blue-gray-4; + +// v2 History +@history-base-font-size : @font-size-small; +@history-base-bg : @ol-blue-gray-1; +@history-entry-day-bg : @ol-blue-gray-2; +@history-base-color : @ol-blue-gray-2; +@history-highlight-color : @ol-type-color; + // System messages @sys-msg-background : @ol-blue; @sys-msg-color : #FFF; From 9f6dc12658ccd852315f113dfc46e0103ffae1df Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 1 May 2018 17:27:51 +0100 Subject: [PATCH 003/135] Add custom styling; add code to handle point-in-time selection. --- .../project/editor/history/entriesListV2.pug | 18 ++--- .../ide/history/HistoryV2Manager.coffee | 37 +++++++--- .../components/historyEntriesList.coffee | 3 +- .../history/components/historyEntry.coffee | 1 + .../HistoryV2ListController.coffee | 67 ++++++++++--------- .../ide/history/util/HistoryViewModes.coffee | 4 ++ .../stylesheets/app/editor/history-v2.less | 9 +++ .../stylesheets/core/_common-variables.less | 11 +-- .../public/stylesheets/core/ol-variables.less | 11 +-- 9 files changed, 103 insertions(+), 58 deletions(-) create mode 100644 services/web/public/coffee/ide/history/util/HistoryViewModes.coffee diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index a19273bfb9..746beb67f1 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -9,6 +9,7 @@ aside.change-list( load-disabled="history.loading || history.atEnd" load-initialize="ui.view == 'history'" is-loading="history.loading" + on-entry-select="handleEntrySelect(selectedEntry)" ) @@ -23,6 +24,7 @@ script(type="text/ng-template", id="historyEntriesListTpl") ng-repeat="entry in $ctrl.entries" entry="entry" current-user="$ctrl.currentUser" + on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })" ) .loading(ng-show="history.loading") i.fa.fa-spin.fa-refresh @@ -31,13 +33,13 @@ script(type="text/ng-template", id="historyEntriesListTpl") script(type="text/ng-template", id="historyEntryTpl") .history-entry( ng-class="{\ - 'first-in-day': $ctrl.entry.meta.first_in_day,\ - 'selected': $ctrl.entry.inSelection,\ - 'selected-to': $ctrl.entry.selectedTo,\ - 'selected-from': $ctrl.entry.selectedFrom,\ - 'hover-selected': $ctrl.entry.inHoverSelection,\ - 'hover-selected-to': $ctrl.entry.hoverSelectedTo,\ - 'hover-selected-from': $ctrl.entry.hoverSelectedFrom,\ + 'history-entry-first-in-day': $ctrl.entry.meta.first_in_day,\ + 'history-entry-selected': $ctrl.entry.inSelection,\ + 'history-entry-selected-to': $ctrl.entry.selectedTo,\ + 'history-entry-selected-from': $ctrl.entry.selectedFrom,\ + 'history-entry-hover-selected': $ctrl.entry.inHoverSelection,\ + 'history-entry-hover-selected-to': $ctrl.entry.hoverSelectedTo,\ + 'history-entry-hover-selected-from': $ctrl.entry.hoverSelectedFrom,\ }" ) @@ -66,7 +68,7 @@ script(type="text/ng-template", id="historyEntryTpl") //- ng-show="$ctrl.entry.beforeSelection || $ctrl.entry.inSelection" ) - .history-entry-details(ng-click="select()") + .history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })") ol.history-entry-changes li.history-entry-change( ng-repeat="pathname in ::$ctrl.entry.pathnames" diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index c2c75a0373..16861d5848 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -2,12 +2,13 @@ define [ "moment" "ide/colors/ColorManager" "ide/history/util/displayNameForUser" + "ide/history/util/HistoryViewModes" "ide/history/controllers/HistoryListController" "ide/history/controllers/HistoryDiffController" "ide/history/directives/infiniteScroll" "ide/history/components/historyEntriesList" "ide/history/components/historyEntry" -], (moment, ColorManager, displayNameForUser) -> +], (moment, ColorManager, displayNameForUser, HistoryViewModes) -> class HistoryManager constructor: (@ide, @$scope) -> @reset() @@ -18,13 +19,13 @@ define [ else @show() - @$scope.$watch "history.selection.updates", (updates) => - if updates? and updates.length > 0 - @_selectDocFromUpdates() - @reloadDiff() + # @$scope.$watch "history.selection.updates", (updates) => + # if updates? and updates.length > 0 + # @_selectDocFromUpdates() + # @reloadDiff() - @$scope.$watch "history.selection.pathname", () => - @reloadDiff() + # @$scope.$watch "history.selection.pathname", () => + # @reloadDiff() show: () -> @$scope.ui.view = "history" @@ -37,6 +38,7 @@ define [ @$scope.history = { isV2: true updates: [] + viewMode: HistoryViewModes.POINT_IN_TIME nextBeforeTimestamp: null atEnd: false selection: { @@ -72,6 +74,21 @@ define [ @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true + autoSelectLastUpdate: () -> + return if @$scope.history.updates.length == 0 + @$scope.history.updates[0].selectedTo = true + @$scope.history.updates[0].selectedFrom = true + + selectUpdate: (update) -> + selectedUpdateIndex = @$scope.history.updates.indexOf update + if selectedUpdateIndex == -1 + selectedUpdateIndex = 0 + for update in @$scope.history.updates + update.selectedTo = false + update.selectedFrom = false + @$scope.history.updates[selectedUpdateIndex].selectedTo = true + @$scope.history.updates[selectedUpdateIndex].selectedFrom = true + BATCH_SIZE: 10 fetchNextBatchOfUpdates: () -> url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" @@ -202,7 +219,11 @@ define [ @$scope.history.updates = @$scope.history.updates.concat(updates) - @autoSelectRecentUpdates() if firstLoad + if firstLoad + if @$scope.history.viewMode == HistoryViewModes.COMPARE + @autoSelectRecentUpdates() + else + @autoSelectLastUpdate() _perDocSummaryOfUpdates: (updates) -> # Track current_pathname -> original_pathname diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee index 8eabf38ebc..5022724714 100644 --- a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -2,7 +2,7 @@ define [ "base" ], (App) -> historyEntriesListController = ($scope, $element, $attrs) -> - ctrl = @ + ctrl = @ return App.component "historyEntriesList", { @@ -13,6 +13,7 @@ define [ loadInitialize: "<" isLoading: "<" currentUser: "<" + onEntrySelect: "&" controller: historyEntriesListController templateUrl: "historyEntriesListTpl" } diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee index dbc63a105b..90b607a628 100644 --- a/services/web/public/coffee/ide/history/components/historyEntry.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -19,6 +19,7 @@ define [ bindings: entry: "<" currentUser: "<" + onSelect: "&" controller: historyEntryController templateUrl: "historyEntryTpl" } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee index 772f360a80..4ea91d7cc8 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -9,6 +9,11 @@ define [ $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() + $scope.handleEntrySelect = (entry) -> + # $scope.$applyAsync () -> + ide.historyManager.selectUpdate(entry) + $scope.recalculateSelectedUpdates() + $scope.recalculateSelectedUpdates = () -> beforeSelection = true afterSelection = false @@ -70,41 +75,41 @@ define [ $scope.recalculateSelectedUpdates() ] - App.controller "HistoryListItemController", ["$scope", "event_tracking", ($scope, event_tracking) -> - $scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) -> - if selectedFrom - for update in $scope.history.updates - update.selectedFrom = false unless update == $scope.update - $scope.recalculateSelectedUpdates() + # App.controller "HistoryListItemController", ["$scope", "event_tracking", ($scope, event_tracking) -> + # $scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) -> + # if selectedFrom + # for update in $scope.history.updates + # update.selectedFrom = false unless update == $scope.update + # $scope.recalculateSelectedUpdates() - $scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) -> - if selectedTo - for update in $scope.history.updates - update.selectedTo = false unless update == $scope.update - $scope.recalculateSelectedUpdates() + # $scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) -> + # if selectedTo + # for update in $scope.history.updates + # update.selectedTo = false unless update == $scope.update + # $scope.recalculateSelectedUpdates() - $scope.select = () -> - event_tracking.sendMB "history-view-change" - $scope.update.selectedTo = true - $scope.update.selectedFrom = true + # $scope.select = () -> + # event_tracking.sendMB "history-view-change" + # $scope.update.selectedTo = true + # $scope.update.selectedFrom = true - $scope.mouseOverSelectedFrom = () -> - $scope.history.hoveringOverListSelectors = true - $scope.update.hoverSelectedFrom = true - $scope.recalculateHoveredUpdates() + # $scope.mouseOverSelectedFrom = () -> + # $scope.history.hoveringOverListSelectors = true + # $scope.update.hoverSelectedFrom = true + # $scope.recalculateHoveredUpdates() - $scope.mouseOutSelectedFrom = () -> - $scope.history.hoveringOverListSelectors = false - $scope.resetHoverState() + # $scope.mouseOutSelectedFrom = () -> + # $scope.history.hoveringOverListSelectors = false + # $scope.resetHoverState() - $scope.mouseOverSelectedTo = () -> - $scope.history.hoveringOverListSelectors = true - $scope.update.hoverSelectedTo = true - $scope.recalculateHoveredUpdates() + # $scope.mouseOverSelectedTo = () -> + # $scope.history.hoveringOverListSelectors = true + # $scope.update.hoverSelectedTo = true + # $scope.recalculateHoveredUpdates() - $scope.mouseOutSelectedTo = () -> - $scope.history.hoveringOverListSelectors = false - $scope.resetHoverState() + # $scope.mouseOutSelectedTo = () -> + # $scope.history.hoveringOverListSelectors = false + # $scope.resetHoverState() - $scope.displayName = displayNameForUser - ] + # $scope.displayName = displayNameForUser + # ] diff --git a/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee new file mode 100644 index 0000000000..125dd87060 --- /dev/null +++ b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee @@ -0,0 +1,4 @@ +define [], () -> + HistoryViewModes = + POINT_IN_TIME : 'point_in_time' + COMPARE : 'compare' diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 426ac70232..6232caf180 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -17,6 +17,12 @@ background-color: #FFF; margin-bottom: 2px; padding: 5px 10px; + cursor: pointer; + + .history-entry-selected & { + background-color: @history-entry-selected-bg; + color: #FFF; + } } .history-entry-changes { .list-unstyled; @@ -33,6 +39,9 @@ color: @history-highlight-color; font-weight: bold; word-break: break-all; + .history-entry-selected & { + color: #FFF; + } } .history-entry-metadata { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 3c1f6b34b2..3afb428692 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -975,8 +975,9 @@ @sys-msg-border : 1px solid @common-border-color; // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @gray-lightest; -@history-entry-day-bg : @gray-dark; -@history-base-color : @gray-light; -@history-highlight-color : @gray; \ No newline at end of file +@history-base-font-size : @font-size-small; +@history-base-bg : @gray-lightest; +@history-entry-day-bg : @gray-dark; +@history-entry-selected-bg : @red; +@history-base-color : @gray-light; +@history-highlight-color : @gray; \ No newline at end of file diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 76b89d9fb5..cd5b035fc4 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -267,11 +267,12 @@ // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @ol-blue-gray-1; -@history-entry-day-bg : @ol-blue-gray-2; -@history-base-color : @ol-blue-gray-2; -@history-highlight-color : @ol-type-color; +@history-base-font-size : @font-size-small; +@history-base-bg : @ol-blue-gray-1; +@history-entry-day-bg : @ol-blue-gray-2; +@history-entry-selected-bg : @ol-green; +@history-base-color : @ol-blue-gray-2; +@history-highlight-color : @ol-type-color; // System messages @sys-msg-background : @ol-blue; From 5cb85c03321c1f7d694387f7eecabc3c9f6a217d Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 3 May 2018 14:29:03 +0100 Subject: [PATCH 004/135] WIP: Add ProjectFileAgent --- .../LinkedFiles/LinkedFilesController.coffee | 5 +- .../LinkedFiles/ProjectFileAgent.coffee | 70 +++++++++++++++++++ .../coffee/infrastructure/FileWriter.coffee | 14 +++- .../ide/file-tree/FileTreeManager.coffee | 12 ++++ 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index aaf4172cf4..bb5a93efb9 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -5,7 +5,8 @@ logger = require 'logger-sharelatex' module.exports = LinkedFilesController = { Agents: { - url: require('./UrlAgent') + url: require('./UrlAgent'), + project_file: require('./ProjectFileAgent') } createLinkedFile: (req, res, next) -> @@ -29,4 +30,4 @@ module.exports = LinkedFilesController = { EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) -> return next(error) if error? res.send(204) # created -} \ No newline at end of file +} diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee new file mode 100644 index 0000000000..116434ef3f --- /dev/null +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -0,0 +1,70 @@ +FileWriter = require('../../infrastructure/FileWriter') +AuthorizationManager = require('../Authorization/AuthorizationManager') +ProjectLocator = require('../Project/ProjectLocator') +DocstoreManager = require('../Docstore/DocstoreManager') +FileStoreHandler = require('../FileStore/FileStoreHandler') +FileWriter = require('../../infrastructure/FileWriter') +_ = require "underscore" +Settings = require 'settings-sharelatex' + + +AccessDeniedError = (message) -> + error = new Error(message) + error.name = 'AccessDenied' + error.__proto__ = AccessDeniedError.prototype + return error +AccessDeniedError.prototype.__proto__ = Error.prototype + +BadEntityTypeError = (message) -> + error = new Error(message) + error.name = 'BadEntityType' + error.__proto__ = BadEntityTypeError.prototype + return error +BadEntityTypeError.prototype.__proto__ = Error.prototype + + +module.exports = ProjectFileAgent = + + sanitizeData: (data) -> + # TODO: + # - Nothing? + return data + + writeIncomingFileToDisk: + (project_id, data, current_user_id, callback = (error, fsPath) ->) -> + callback = _.once(callback) + {source_project_id, source_entity_path} = data + AuthorizationManager.canUserReadProject current_user_id, source_project_id, + null, (err, canRead) -> + return callback(err) if err? + return callback(new AccessDeniedError()) if !canRead + ProjectLocator.findElementByPath { + project_id: source_project_id, + path: source_entity_path + }, (err, entity, type) -> + return callback(err) if err? # also applies when file not found + ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback + + _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) -> + callback = _.once(callback) + if type == 'doc' + DocstoreManager.getDoc project_id, entity_id, (err, lines) -> + return callback(err) if err? + FileWriter.writeLinesToDisk entity_id, lines, callback + else if type == 'file' + FileStoreHandler.getFileStream project_id, entity_id, (err, fileStream) -> + return callback(err) if err? + FileWriter.writeStreamToDisk entity_id, fileStream, callback + else + callback(new BadEntityTypeError()) + + handleError: (error, req, res, next) -> + if error instanceof AccessDeniedError + res.status(403).send("You do not have access to this project") + else if error instanceof FileNotFoundError + res.status(404).send("The file does not exist") + else if error instanceof BadEntityTypeError + res.status(404).send("The file is the wrong type") # TODO: better error message + else + next(error) + next() diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee index dedeed9bad..21353e7a36 100644 --- a/services/web/app/coffee/infrastructure/FileWriter.coffee +++ b/services/web/app/coffee/infrastructure/FileWriter.coffee @@ -6,6 +6,18 @@ Settings = require 'settings-sharelatex' request = require 'request' module.exports = FileWriter = + + writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) -> + callback = _.once(callback) + fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" + fs.mkdir Settings.path.dumpFolder, (error) -> + if error? and error.code != 'EEXIST' + # Ignore error about already existing + return callback(error) + fs.writeFile fsPath, lines.join('\n'), (error) -> + return callback(error) if error? + callback(null, fsPath) + writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) -> callback = _.once(callback) fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" @@ -39,4 +51,4 @@ module.exports = FileWriter = else err = new Error("bad response from url: #{response.statusCode}") logger.err {err, identifier, url}, err.message - callback(err) \ No newline at end of file + callback(err) diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index d7a428ec80..f342843a54 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -25,6 +25,18 @@ define [ $(document).on "click", => @clearMultiSelectedEntities() @$scope.$digest() + window.doLinkedFileImportFromProject = (project, path, name) => + parent_folder = @getCurrentFolder() + @ide.$http.post "/project/#{@ide.project_id}/linked_file", { + name: name, + parent_folder_id: parent_folder?.id + provider: 'project_file', + data: { + source_project_id: project, + source_entity_path: path + }, + _csrf: window.csrfToken + } _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => From 87fb226c3edeccbd05973301cb5b92e3f6149fd8 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 3 May 2018 15:30:44 +0100 Subject: [PATCH 005/135] Fix invocation of `getFileStream` --- .../web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 116434ef3f..60f5c394f4 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -52,7 +52,7 @@ module.exports = ProjectFileAgent = return callback(err) if err? FileWriter.writeLinesToDisk entity_id, lines, callback else if type == 'file' - FileStoreHandler.getFileStream project_id, entity_id, (err, fileStream) -> + FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) -> return callback(err) if err? FileWriter.writeStreamToDisk entity_id, fileStream, callback else From 4925bfe5364a02e172b13946af29b9003699e578 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 4 May 2018 09:44:13 +0100 Subject: [PATCH 006/135] Add an endpoint to get users projects as json --- .../Features/Project/ProjectController.coffee | 13 +++++++++++++ services/web/app/coffee/router.coffee | 1 + .../coffee/ide/file-tree/FileTreeManager.coffee | 10 +++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 9312ba0b1b..19374a1975 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -138,6 +138,19 @@ module.exports = ProjectController = return next(err) if err? res.sendStatus 200 + projectsJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + + ProjectGetter.findAllUsersProjects user_id, + 'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) -> + return next(err) if err? + projects = ProjectController._buildProjectList(projects) + .filter((p) -> !p.archived) + .filter((p) -> !p.isV1Project) + .map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel}) + + res.json({projects: projects}) + projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") user_id = AuthenticationController.getLoggedInUserId(req) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 7ec4dafbf4..83fae46131 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -118,6 +118,7 @@ module.exports = class Router webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo + webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.projectsJson webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index f342843a54..a5784c57fd 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -25,7 +25,9 @@ define [ $(document).on "click", => @clearMultiSelectedEntities() @$scope.$digest() - window.doLinkedFileImportFromProject = (project, path, name) => + + # TODO: remove + window._doLinkedFileImportFromProject = (project, path, name) => parent_folder = @getCurrentFolder() @ide.$http.post "/project/#{@ide.project_id}/linked_file", { name: name, @@ -38,6 +40,12 @@ define [ _csrf: window.csrfToken } + # TODO: remove + window._getProjects = () => + @ide.$http.get "/user/projects", { + _csrf: window.csrfToken + } + _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder From 3c3ce2010a53e0d98263ff3e084af31bff7822cb Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 4 May 2018 10:45:13 +0100 Subject: [PATCH 007/135] Add endpoint to list entities within a project --- .../Features/Project/ProjectController.coffee | 22 +++++++++++++++++-- services/web/app/coffee/router.coffee | 5 ++++- .../ide/file-tree/FileTreeManager.coffee | 5 +++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 19374a1975..3d1d11da71 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources" TokenAccessHandler = require '../TokenAccess/TokenAccessHandler' CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler' Modules = require '../../infrastructure/Modules' +ProjectEntityHandler = require './ProjectEntityHandler' crypto = require 'crypto' module.exports = ProjectController = @@ -138,9 +139,8 @@ module.exports = ProjectController = return next(err) if err? res.sendStatus 200 - projectsJson: (req, res, next) -> + userProjectsJson: (req, res, next) -> user_id = AuthenticationController.getLoggedInUserId(req) - ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) -> return next(err) if err? @@ -151,6 +151,24 @@ module.exports = ProjectController = res.json({projects: projects}) + projectEntitiesJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + project_id = req.params.Project_id + AuthorizationManager.canUserReadProject user_id, project_id, + null, (err, canRead) -> + return next(err) if err? + return res.status(403) if !canRead + ProjectGetter.getProject project_id, (err, project) -> + return next(err) if err? + ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> + return next(err) if err? + entities = docs.concat(files) + .map (e) -> { + path: e.path, + type: if e.doc? then 'doc' else 'file' + } + res.json({entities: entities}) + projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") user_id = AuthenticationController.getLoggedInUserId(req) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 83fae46131..6d94aa65b1 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -118,7 +118,10 @@ module.exports = class Router webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo - webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.projectsJson + # TODO: check this is the right router for these routes + webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson + webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), ProjectController.projectEntitiesJson + webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index a5784c57fd..01fa211b1f 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -46,6 +46,11 @@ define [ _csrf: window.csrfToken } + window._getProjectEntities = (project_id) => + @ide.$http.get "/project/#{project_id}/entities", { + _csrf: window.csrfToken + } + _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder From 08263180fa1c6bf16c2d7a192724841db7d9f608 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 4 May 2018 11:03:54 +0100 Subject: [PATCH 008/135] Add project_id to the entities payload --- .../web/app/coffee/Features/Project/ProjectController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 3d1d11da71..b69c1e412b 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -167,7 +167,7 @@ module.exports = ProjectController = path: e.path, type: if e.doc? then 'doc' else 'file' } - res.json({entities: entities}) + res.json({project_id: project_id, entities: entities}) projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") From 30beb098ab2a0fc776339d9f8cac8fd6d031712c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 4 May 2018 11:06:59 +0100 Subject: [PATCH 009/135] Sort the project entities by path --- .../web/app/coffee/Features/Project/ProjectController.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index b69c1e412b..5abf03ad00 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -163,6 +163,7 @@ module.exports = ProjectController = ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> return next(err) if err? entities = docs.concat(files) + .sort (a, b) -> a.path > b.path # Sort by path ascending .map (e) -> { path: e.path, type: if e.doc? then 'doc' else 'file' From 9c33f3f8bc05409b3942f27087530688fb38af76 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 15 May 2018 16:22:47 +0100 Subject: [PATCH 010/135] WIP: Project Linked File modal --- .../app/views/project/editor/file-tree.pug | 54 ++++++++++ .../ide/file-tree/FileTreeManager.coffee | 8 +- .../controllers/FileTreeController.coffee | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 55fc660abc..ec26c8d503 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -342,6 +342,60 @@ script(type='text/ng-template', id='newDocModalTemplate') span(ng-show="state.inflight") #{translate("creating")}... +// Project Linked Files Modal +script(type='text/ng-template', id='projectLinkedFileModalTemplate') + .modal-header + h3 New file from Project + + .modal-body + div + div.alert.alert-danger(ng-if="state.error") Error + div + form + .form-controls + label(for="project-select") Select a Project + select.form-control( + name="project-select" + ng-model="data.selectedProject" + ng-disabled="!shouldEnableProjectSelect()" + ) + option(value="") -- + option( + ng-repeat="project in data.projects" + value="{{ project._id }}" + ) {{ project.name }} + + br + .form-controls + label(for="project-entity-select") Select a File + select.form-control( + name="project-entity-select" + ng-model="data.selectedProjectEntity" + ng-disabled="!shouldEnableProjectEntitySelect()" + ) + option(value="") -- + option( + ng-repeat="projectEntity in data.projectEntities" + value="{{ projectEntity.path }}" + ) {{ projectEntity.path }} + br + + .modal-footer + span(ng-show="state.inFlight") + i.fa.fa-spinner.fa-spin + |   + button.btn.btn-default( + ng-disabled="state.inflight" + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-primary( + ng-disabled="!shouldEnableCreateButton()" + ng-click="create()" + ) + span(ng-hide="state.inflight") #{translate("create")} + span(ng-show="state.inflight") #{translate("creating")}... + + script(type='text/ng-template', id='linkedFileModalTemplate') .modal-header h3 New file from URL diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 01fa211b1f..8536db63c7 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -42,14 +42,14 @@ define [ # TODO: remove window._getProjects = () => - @ide.$http.get "/user/projects", { + @ide.$http.get("/user/projects", { _csrf: window.csrfToken - } + }).then (resp) -> console.log(resp.status, resp.data) window._getProjectEntities = (project_id) => - @ide.$http.get "/project/#{project_id}/entities", { + @ide.$http.get("/project/#{project_id}/entities", { _csrf: window.csrfToken - } + }).then (resp) -> console.log(resp.status, resp.data) _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 3d4077b2dd..cf03602d92 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -43,6 +43,19 @@ define [ } ) + $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () -> + unless 'url' in window.data.enabledLinkedFileTypes + console.warn("Project linked files are not enabled") + return + $modal.open( + templateUrl: "projectLinkedFileModalTemplate" + controller: "ProjectLinkedFileModalController" + scope: $scope + resolve: { + parent_folder: () -> ide.fileTreeManager.getCurrentFolder() + } + ) + $scope.orderByFoldersFirst = (entity) -> return '0' if entity?.type == "folder" return '1' @@ -201,6 +214,91 @@ define [ $modalInstance.dismiss('cancel') ] + App.controller "ProjectLinkedFileModalController", [ + "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", + ($scope, ide, $modalInstance, $timeout, parent_folder) -> + $scope.data = + projects: null # or [] + selectedProject: null + projectEntities: null # or [] + selectedProjectEntity: null + $scope.state = + inFlight: false + error: false + + $scope.$watch 'data.selectedProject', (newVal, oldVal) -> + return if !newVal + $scope.data.selectedProjectEntity = null + $scope.getProjectEntities($scope.data.selectedProject) + + $scope._reset = () -> + $scope.state.inFlight = false + $scope.state.error = false + + $scope._resetAfterResponse = (opts) -> + isError = !!opts.err + $scope.state.inFlight = false + $scope.state.error = isError + + $scope.shouldEnableProjectSelect = () -> + state = $scope.state + data = $scope.data + return !state.inFlight && data.projects + + $scope.shouldEnableProjectEntitySelect = () -> + state = $scope.state + data = $scope.data + return !state.inFlight && data.projects && data.selectedProject + + $scope.shouldEnableCreateButton = () -> + state = $scope.state + data = $scope.data + return !state.inFlight && + data.projects && + data.selectedProject && + data.projectEntities && + data.selectedProjectEntity + + $scope.getUserProjects = () -> + $scope.state.inFlight = true + ide.$http.get("/user/projects", { + _csrf: window.csrfToken + }) + .then (resp) -> + $scope.data.projectEntities = null + $scope.data.projects = resp.data.projects + $scope._resetAfterResponse(err: false) + .catch (err) -> + $scope._resetAfterResponse(err: true) + + $scope.getProjectEntities = (project_id) => + $scope.state.inFlight = true + ide.$http.get("/project/#{project_id}/entities", { + _csrf: window.csrfToken + }) + .then (resp) -> + if $scope.data.selectedProject == resp.data.project_id + $scope.data.projectEntities = resp.data.entities + $scope._resetAfterResponse(err: false) + .catch (err) -> + $scope._resetAfterResponse(err: true) + + # TODO: remove + window._S = $scope + + $scope.init = () -> + $scope.getUserProjects() + $timeout($scope.init, 100) + + $scope.create = () -> + console.log ">> create" + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + ] + + # TODO: rename all this to UrlLinkedFilModalController App.controller "LinkedFileModalController", [ "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", ($scope, ide, $modalInstance, $timeout, parent_folder) -> From 103832af7d0aab175211a3bd03733114c2fa8d78 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 10:29:51 +0100 Subject: [PATCH 011/135] Functioning project-linked-file importer --- .../app/views/project/editor/file-tree.pug | 15 ++++++++-- .../controllers/FileTreeController.coffee | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index ec26c8d503..41de992d61 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -359,7 +359,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') ng-model="data.selectedProject" ng-disabled="!shouldEnableProjectSelect()" ) - option(value="") -- + option(value="") No Project Selected option( ng-repeat="project in data.projects" value="{{ project._id }}" @@ -373,13 +373,24 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') ng-model="data.selectedProjectEntity" ng-disabled="!shouldEnableProjectEntitySelect()" ) - option(value="") -- + option(value="") No File Selected option( ng-repeat="projectEntity in data.projectEntities" value="{{ projectEntity.path }}" ) {{ projectEntity.path }} br + .form-controls + label(for="name") File Name In This Project + input.form-control( + type="text" + placeholder="example.tex" + required + ng-model="data.name" + name="name" + ) + br + .modal-footer span(ng-show="state.inFlight") i.fa.fa-spinner.fa-spin diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index cf03602d92..0d8c43c7f3 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -222,6 +222,7 @@ define [ selectedProject: null projectEntities: null # or [] selectedProjectEntity: null + name: null $scope.state = inFlight: false error: false @@ -231,12 +232,8 @@ define [ $scope.data.selectedProjectEntity = null $scope.getProjectEntities($scope.data.selectedProject) - $scope._reset = () -> - $scope.state.inFlight = false - $scope.state.error = false - $scope._resetAfterResponse = (opts) -> - isError = !!opts.err + isError = opts.err == true $scope.state.inFlight = false $scope.state.error = isError @@ -257,7 +254,8 @@ define [ data.projects && data.selectedProject && data.projectEntities && - data.selectedProjectEntity + data.selectedProjectEntity && + data.name $scope.getUserProjects = () -> $scope.state.inFlight = true @@ -266,7 +264,8 @@ define [ }) .then (resp) -> $scope.data.projectEntities = null - $scope.data.projects = resp.data.projects + $scope.data.projects = resp.data.projects.filter (p) -> + p._id != ide.project_id $scope._resetAfterResponse(err: false) .catch (err) -> $scope._resetAfterResponse(err: true) @@ -291,7 +290,21 @@ define [ $timeout($scope.init, 100) $scope.create = () -> - console.log ">> create" + project = $scope.data.selectedProject + path = $scope.data.selectedProjectEntity + name = $scope.data.name + $scope.state.inFlight = true + ide.fileTreeManager + .createLinkedFile(name, parent_folder, 'project_file', { + source_project_id: project, + source_entity_path: path + }) + .then () -> + $scope._resetAfterResponse(err: false) + $modalInstance.close() + .catch (response)-> + { data } = response + $scope._resetAfterResponse(err: true) $scope.cancel = () -> $modalInstance.dismiss('cancel') From f2702c7b0a9567eda660a5940b4c12d49550ae4d Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 10:41:21 +0100 Subject: [PATCH 012/135] Show the linked-files UI for project-linked-files --- .../web/app/views/project/editor/binary-file.pug | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index e5690c1874..d7a3ce14f6 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -47,7 +47,18 @@ div.binary-file.full-size( | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} - span(ng-if="openFile.linkedFileData.provider == 'url'") + div(ng-if="openFile.linkedFileData.provider == 'project_file'") + p + i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon + | Imported from + | + // TODO: Show the project name instead + a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}') + | {{ displayUrl(openFile.linkedFileData.source_project_id) }} + | + | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} + + span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'") button.btn.btn-success( href, ng-click="refreshFile(openFile)", ng-disabled="refreshing" From 74d8e67a052ebac1ae4be65af66368907f62cc69 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 10:58:56 +0100 Subject: [PATCH 013/135] Remove leading slash from path names, for display --- services/web/app/views/project/editor/file-tree.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 41de992d61..d862cd3b54 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -377,7 +377,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') option( ng-repeat="projectEntity in data.projectEntities" value="{{ projectEntity.path }}" - ) {{ projectEntity.path }} + ) {{ projectEntity.path.slice(1) }} br .form-controls From e3bc6cac9e9bd6dd7abb058ac5951c161e8a3140 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 11:12:00 +0100 Subject: [PATCH 014/135] Auto-set filename based on selected file --- .../ide/file-tree/controllers/FileTreeController.coffee | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 0d8c43c7f3..4f816efe2a 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -232,6 +232,13 @@ define [ $scope.data.selectedProjectEntity = null $scope.getProjectEntities($scope.data.selectedProject) + # auto-set filename based on selected file + $scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) -> + return if !newVal + fileName = newVal.split('/').reverse()[0] + if fileName + $scope.data.name = fileName + $scope._resetAfterResponse = (opts) -> isError = opts.err == true $scope.state.inFlight = false From 7292602167669af4c693dbe14486f5c6b71366cd Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 11:49:10 +0100 Subject: [PATCH 015/135] More fine-grained loading spinners --- .../app/views/project/editor/file-tree.pug | 14 +++++++++---- .../controllers/FileTreeController.coffee | 21 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index d862cd3b54..d1b037925a 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -349,17 +349,20 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') .modal-body div - div.alert.alert-danger(ng-if="state.error") Error + div.alert.alert-danger(ng-if="state.error") Error, something went wrong! div form .form-controls label(for="project-select") Select a Project + span(ng-show="state.inFlight.projects") + |   + i.fa.fa-spinner.fa-spin select.form-control( name="project-select" ng-model="data.selectedProject" ng-disabled="!shouldEnableProjectSelect()" ) - option(value="") No Project Selected + option(value="") - No Project Selected option( ng-repeat="project in data.projects" value="{{ project._id }}" @@ -368,12 +371,15 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') br .form-controls label(for="project-entity-select") Select a File + span(ng-show="state.inFlight.entities") + |   + i.fa.fa-spinner.fa-spin select.form-control( name="project-entity-select" ng-model="data.selectedProjectEntity" ng-disabled="!shouldEnableProjectEntitySelect()" ) - option(value="") No File Selected + option(value="") - No File Selected option( ng-repeat="projectEntity in data.projectEntities" value="{{ projectEntity.path }}" @@ -392,7 +398,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') br .modal-footer - span(ng-show="state.inFlight") + span(ng-show="state.inFlight.create") i.fa.fa-spinner.fa-spin |   button.btn.btn-default( diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 4f816efe2a..42f676b0ae 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -224,7 +224,10 @@ define [ selectedProjectEntity: null name: null $scope.state = - inFlight: false + inFlight: + projects: false + entities: false + create: false error: false $scope.$watch 'data.selectedProject', (newVal, oldVal) -> @@ -241,23 +244,25 @@ define [ $scope._resetAfterResponse = (opts) -> isError = opts.err == true - $scope.state.inFlight = false + inFlight = $scope.state.inFlight + inFlight.projects = inFlight.entities = inFlight.create = false $scope.state.error = isError $scope.shouldEnableProjectSelect = () -> state = $scope.state data = $scope.data - return !state.inFlight && data.projects + return !state.inFlight.projects && data.projects $scope.shouldEnableProjectEntitySelect = () -> state = $scope.state data = $scope.data - return !state.inFlight && data.projects && data.selectedProject + return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProject $scope.shouldEnableCreateButton = () -> state = $scope.state data = $scope.data - return !state.inFlight && + return !state.inFlight.projects && + !state.inFlight.entities && data.projects && data.selectedProject && data.projectEntities && @@ -265,7 +270,7 @@ define [ data.name $scope.getUserProjects = () -> - $scope.state.inFlight = true + $scope.state.inFlight.projects = true ide.$http.get("/user/projects", { _csrf: window.csrfToken }) @@ -278,7 +283,7 @@ define [ $scope._resetAfterResponse(err: true) $scope.getProjectEntities = (project_id) => - $scope.state.inFlight = true + $scope.state.inFlight.entities = true ide.$http.get("/project/#{project_id}/entities", { _csrf: window.csrfToken }) @@ -300,7 +305,7 @@ define [ project = $scope.data.selectedProject path = $scope.data.selectedProjectEntity name = $scope.data.name - $scope.state.inFlight = true + $scope.state.inFlight.create = true ide.fileTreeManager .createLinkedFile(name, parent_folder, 'project_file', { source_project_id: project, From f533674dbd6bc719479d3f1393034f552ed96d7d Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 13:26:59 +0100 Subject: [PATCH 016/135] Clean up --- .../app/views/project/editor/binary-file.pug | 2 +- .../app/views/project/editor/file-tree.pug | 2 +- .../controllers/FileTreeController.coffee | 43 ++++++++++--------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index d7a3ce14f6..eeb633a318 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -52,9 +52,9 @@ div.binary-file.full-size( i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon | Imported from | - // TODO: Show the project name instead a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}') | {{ displayUrl(openFile.linkedFileData.source_project_id) }} + |  / {{ openFile.linkedFileData.source_entity_path.slice(1) }}, | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index d1b037925a..67a3a18f04 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -359,7 +359,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') i.fa.fa-spinner.fa-spin select.form-control( name="project-select" - ng-model="data.selectedProject" + ng-model="data.selectedProjectId" ng-disabled="!shouldEnableProjectSelect()" ) option(value="") - No Project Selected diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 42f676b0ae..33a4bf495e 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -219,7 +219,7 @@ define [ ($scope, ide, $modalInstance, $timeout, parent_folder) -> $scope.data = projects: null # or [] - selectedProject: null + selectedProjectId: null projectEntities: null # or [] selectedProjectEntity: null name: null @@ -230,10 +230,10 @@ define [ create: false error: false - $scope.$watch 'data.selectedProject', (newVal, oldVal) -> + $scope.$watch 'data.selectedProjectId', (newVal, oldVal) -> return if !newVal $scope.data.selectedProjectEntity = null - $scope.getProjectEntities($scope.data.selectedProject) + $scope.getProjectEntities($scope.data.selectedProjectId) # auto-set filename based on selected file $scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) -> @@ -242,7 +242,10 @@ define [ if fileName $scope.data.name = fileName - $scope._resetAfterResponse = (opts) -> + $scope._setInFlight = (type) -> + $scope.state.inFlight[type] = true + + $scope._reset = (opts) -> isError = opts.err == true inFlight = $scope.state.inFlight inFlight.projects = inFlight.entities = inFlight.create = false @@ -256,7 +259,7 @@ define [ $scope.shouldEnableProjectEntitySelect = () -> state = $scope.state data = $scope.data - return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProject + return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId $scope.shouldEnableCreateButton = () -> state = $scope.state @@ -264,13 +267,13 @@ define [ return !state.inFlight.projects && !state.inFlight.entities && data.projects && - data.selectedProject && + data.selectedProjectId && data.projectEntities && data.selectedProjectEntity && data.name $scope.getUserProjects = () -> - $scope.state.inFlight.projects = true + $scope._setInFlight('projects') ide.$http.get("/user/projects", { _csrf: window.csrfToken }) @@ -278,45 +281,45 @@ define [ $scope.data.projectEntities = null $scope.data.projects = resp.data.projects.filter (p) -> p._id != ide.project_id - $scope._resetAfterResponse(err: false) + $scope._reset(err: false) .catch (err) -> - $scope._resetAfterResponse(err: true) + $scope._reset(err: true) $scope.getProjectEntities = (project_id) => - $scope.state.inFlight.entities = true + $scope._setInFlight('entities') ide.$http.get("/project/#{project_id}/entities", { _csrf: window.csrfToken }) .then (resp) -> - if $scope.data.selectedProject == resp.data.project_id + if $scope.data.selectedProjectId == resp.data.project_id $scope.data.projectEntities = resp.data.entities - $scope._resetAfterResponse(err: false) + $scope._reset(err: false) .catch (err) -> - $scope._resetAfterResponse(err: true) - - # TODO: remove - window._S = $scope + $scope._reset(err: true) $scope.init = () -> $scope.getUserProjects() $timeout($scope.init, 100) $scope.create = () -> - project = $scope.data.selectedProject + project = $scope.data.selectedProjectId path = $scope.data.selectedProjectEntity name = $scope.data.name - $scope.state.inFlight.create = true + if !name || !path || !project + $scope._reset(err: true) + return + $scope._setInFlight('create') ide.fileTreeManager .createLinkedFile(name, parent_folder, 'project_file', { source_project_id: project, source_entity_path: path }) .then () -> - $scope._resetAfterResponse(err: false) + $scope._reset(err: false) $modalInstance.close() .catch (response)-> { data } = response - $scope._resetAfterResponse(err: true) + $scope._reset(err: true) $scope.cancel = () -> $modalInstance.dismiss('cancel') From 9624e2a290390f35a746680588a3c118b7924cc1 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 13:44:21 +0100 Subject: [PATCH 017/135] Record the source project display name, to render with the file --- services/web/app/views/project/editor/binary-file.pug | 6 +++--- .../file-tree/controllers/FileTreeController.coffee | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index eeb633a318..921ed5571d 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -52,9 +52,9 @@ div.binary-file.full-size( i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon | Imported from | - a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}') - | {{ displayUrl(openFile.linkedFileData.source_project_id) }} - |  / {{ openFile.linkedFileData.source_entity_path.slice(1) }}, + a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank") + | {{ openFile.linkedFileData.source_project_display_name }} + |  {{ openFile.linkedFileData.source_entity_path.slice(1) }}, | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 33a4bf495e..bf5a6af511 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -302,17 +302,19 @@ define [ $timeout($scope.init, 100) $scope.create = () -> - project = $scope.data.selectedProjectId + projectId = $scope.data.selectedProjectId + projectDisplayName = _.find($scope.data.projects, (p) -> p._id == projectId).name path = $scope.data.selectedProjectEntity name = $scope.data.name - if !name || !path || !project + if !name || !path || !projectId || !projectDisplayName $scope._reset(err: true) return $scope._setInFlight('create') ide.fileTreeManager .createLinkedFile(name, parent_folder, 'project_file', { - source_project_id: project, - source_entity_path: path + source_project_id: projectId, + source_entity_path: path, + source_project_display_name: projectDisplayName }) .then () -> $scope._reset(err: false) From c626446aad2ad2ee311354d19543cd28f5729bcf Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 May 2018 13:52:54 +0100 Subject: [PATCH 018/135] Tidy up the project/file display in project-linked-file --- services/web/app/views/project/editor/binary-file.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index 921ed5571d..5171483d24 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -54,7 +54,7 @@ div.binary-file.full-size( | a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank") | {{ openFile.linkedFileData.source_project_display_name }} - |  {{ openFile.linkedFileData.source_entity_path.slice(1) }}, + | /{{ openFile.linkedFileData.source_entity_path.slice(1) }}, | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} From 2345b77ea7cbddc81e91a7213857c62693fc868c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 17 May 2018 10:51:58 +0100 Subject: [PATCH 019/135] Validate project-linked-file data before doing import --- .../LinkedFiles/ProjectFileAgent.coffee | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 60f5c394f4..f71889fd85 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -15,6 +15,7 @@ AccessDeniedError = (message) -> return error AccessDeniedError.prototype.__proto__ = Error.prototype + BadEntityTypeError = (message) -> error = new Error(message) error.name = 'BadEntityType' @@ -23,16 +24,31 @@ BadEntityTypeError = (message) -> BadEntityTypeError.prototype.__proto__ = Error.prototype +BadDataError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = BadDataError.prototype + return error +BadDataError.prototype.__proto__ = Error.prototype + + module.exports = ProjectFileAgent = sanitizeData: (data) -> - # TODO: - # - Nothing? return data + _validate: (data) -> + return ( + !!data.source_project_id && + !!data.source_entity_path && + !!data.source_project_display_name + ) + writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) -> callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) {source_project_id, source_entity_path} = data AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) -> @@ -61,10 +77,10 @@ module.exports = ProjectFileAgent = handleError: (error, req, res, next) -> if error instanceof AccessDeniedError res.status(403).send("You do not have access to this project") - else if error instanceof FileNotFoundError - res.status(404).send("The file does not exist") + else if error instanceof BadDataError + res.status(400).send("The submitted data is not valid") else if error instanceof BadEntityTypeError - res.status(404).send("The file is the wrong type") # TODO: better error message + res.status(404).send("The file is the wrong type") else next(error) next() From 94a599d5303994f5399d5588f8d2d35b7a40e15f Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 18 May 2018 10:35:02 +0100 Subject: [PATCH 020/135] Fix the reloading of file view after refreshing linked file --- .../LinkedFiles/LinkedFilesController.coffee | 4 ++-- .../ide/binary-files/BinaryFilesManager.coffee | 14 ++++++++++++++ .../controllers/BinaryFileController.coffee | 11 +++++++++-- .../coffee/ide/file-tree/FileTreeManager.coffee | 9 +++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index bb5a93efb9..c9edeefe32 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -27,7 +27,7 @@ module.exports = LinkedFilesController = { if error? logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' return Agent.handleError(error, req, res, next) - EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) -> + EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> return next(error) if error? - res.send(204) # created + res.json(new_file_id: file._id) # created } diff --git a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee index ebecf1132e..fc86208717 100644 --- a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee +++ b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee @@ -19,3 +19,17 @@ define [ , 0 , this ) + + openFileById: (id) -> + file = @ide.fileTreeManager.selectEntityById(id) + @$scope.ui.view = "file" + @$scope.openFile = null + @$scope.$apply() + window.setTimeout( + () => + @$scope.openFile = file + @$scope.$apply() + @$scope.$digest() + , 0 + , this + ) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index c14e097842..cdc8cd5d2c 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -49,8 +49,15 @@ define [ $scope.refreshFile = (file) -> $scope.refreshing = true ide.fileTreeManager.refreshLinkedFile(file) - .then () -> - loadTextFileFilePreview() + .then (response) -> + { data } = response + new_file_id = data.new_file_id + $timeout( + () -> + ide.binaryFilesManager.openFileById(new_file_id) + , 1000 + ) + # loadTextFileFilePreview() .finally () -> $scope.refreshing = false diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 8536db63c7..2cd6ea4031 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -112,6 +112,15 @@ define [ entity.selected = false entity.selected = true + selectEntityById: (entity_id) -> + @selected_entity_id = entity_id # For reselecting after a reconnect + selected_entity = null + @ide.fileTreeManager.forEachEntity (entity) -> + if entity.id == entity_id + selected_entity = entity + entity.selected = true + return selected_entity + toggleMultiSelectEntity: (entity) -> entity.multiSelected = !entity.multiSelected @$scope.multiSelectedCount = @multiSelectedCount() From 16106df2f04b1f30110ca0febb8abc5c1287e813 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 18 May 2018 11:05:20 +0100 Subject: [PATCH 021/135] Remove obsolete code --- .../ide/binary-files/controllers/BinaryFileController.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index cdc8cd5d2c..9406c1f900 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -57,7 +57,6 @@ define [ ide.binaryFilesManager.openFileById(new_file_id) , 1000 ) - # loadTextFileFilePreview() .finally () -> $scope.refreshing = false From 4acd55b1c630b902b215aadcce92247a671a056c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 18 May 2018 11:07:59 +0100 Subject: [PATCH 022/135] More tidy unpacking of data --- .../ide/binary-files/controllers/BinaryFileController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 9406c1f900..9a89a87b6f 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -51,7 +51,7 @@ define [ ide.fileTreeManager.refreshLinkedFile(file) .then (response) -> { data } = response - new_file_id = data.new_file_id + { new_file_id } = data $timeout( () -> ide.binaryFilesManager.openFileById(new_file_id) From 2b99080ed3c3d4a90da937a9a28721e8b099a966 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 18 May 2018 11:25:01 +0100 Subject: [PATCH 023/135] Fix rendering of long previews, stop cutting off last line in short ones --- .../ide/binary-files/controllers/BinaryFileController.coffee | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 9a89a87b6f..31b0f93f03 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -92,11 +92,9 @@ define [ # show dots when payload is closs to cutoff if data.length >= (TWO_MEGABYTES - 200) $scope.textPreview.shouldShowDots = true - try # remove last partial line data = data.replace(/\n.*$/, '') - finally - $scope.textPreview.data = data + $scope.textPreview.data = data $timeout(setHeight, 0) .catch (error) -> console.error(error) From ee1b32eee162003986fc460992a610f7b74c54e7 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Mon, 21 May 2018 10:12:41 +0100 Subject: [PATCH 024/135] Check for case where the source file is not found --- .../LinkedFiles/ProjectFileAgent.coffee | 18 ++++++++++++++++-- .../Features/LinkedFiles/UrlAgent.coffee | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index f71889fd85..3e434d5361 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -32,6 +32,14 @@ BadDataError = (message) -> BadDataError.prototype.__proto__ = Error.prototype +FileNotFoundError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = FileNotFoundError.prototype + return error +FileNotFoundError.prototype.__proto__ = Error.prototype + + module.exports = ProjectFileAgent = sanitizeData: (data) -> @@ -58,7 +66,11 @@ module.exports = ProjectFileAgent = project_id: source_project_id, path: source_entity_path }, (err, entity, type) -> - return callback(err) if err? # also applies when file not found + # return callback(err) if err? # also applies when file not found + if err? + if err.toString().match(/^not found.*/) + err = new FileNotFoundError() + return callback(err) ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) -> @@ -80,7 +92,9 @@ module.exports = ProjectFileAgent = else if error instanceof BadDataError res.status(400).send("The submitted data is not valid") else if error instanceof BadEntityTypeError - res.status(404).send("The file is the wrong type") + res.status(400).send("The file is the wrong type") + else if error instanceof FileNotFoundError + res.status(404).send("File not found") else next(error) next() diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee index ad96aa628f..567a1b4c39 100644 --- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee @@ -65,4 +65,4 @@ module.exports = UrlAgent = { if !Settings.apis?.linkedUrlProxy?.url? throw new Error('no linked url proxy configured') return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}" -} \ No newline at end of file +} From 7d8c7bebe2b74b30ebabf2332f4bb5f6a0fa5777 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Mon, 21 May 2018 10:17:00 +0100 Subject: [PATCH 025/135] Remove commented-out code --- .../web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 3e434d5361..514792eb64 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -66,7 +66,6 @@ module.exports = ProjectFileAgent = project_id: source_project_id, path: source_entity_path }, (err, entity, type) -> - # return callback(err) if err? # also applies when file not found if err? if err.toString().match(/^not found.*/) err = new FileNotFoundError() From 1f2ee4e3fcf4a526abee1d2bf7042b8f5429c706 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Mon, 21 May 2018 11:02:12 +0100 Subject: [PATCH 026/135] Show error if refresh fails --- services/web/app/views/project/editor/binary-file.pug | 4 ++++ .../ide/binary-files/controllers/BinaryFileController.coffee | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index 5171483d24..35d75c9fb3 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -74,3 +74,7 @@ div.binary-file.full-size( i.fa.fa-fw.fa-download | | #{translate("download")} + div(ng-if="refreshError") + br + .alert.alert-danger.col-md-6.col-md-offset-3 + | Error: {{ refreshError}} diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 31b0f93f03..3ccffc9123 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -31,6 +31,7 @@ define [ data: null $scope.refreshing = false + $scope.refreshError = null MAX_URL_LENGTH = 60 FRONT_OF_URL_LENGTH = 35 @@ -48,6 +49,7 @@ define [ $scope.refreshFile = (file) -> $scope.refreshing = true + $scope.refreshError = null ide.fileTreeManager.refreshLinkedFile(file) .then (response) -> { data } = response @@ -57,6 +59,9 @@ define [ ide.binaryFilesManager.openFileById(new_file_id) , 1000 ) + $scope.refreshError = null + .catch (response) -> + $scope.refreshError = response.data .finally () -> $scope.refreshing = false From 2a52eab8d6a5a707d55374cc80d89703f560c8dc Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:10:46 +0100 Subject: [PATCH 027/135] Proxy history filetree requests through web. --- services/web/app/coffee/router.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 7ec4dafbf4..38e3f8ea57 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -200,6 +200,7 @@ module.exports = class Router webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails + webRouter.get "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2 From 6701b4413ba84bc6a1be4951a3b2b0f871fec5d2 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:12:03 +0100 Subject: [PATCH 028/135] Add history file tree components. --- .../history/components/historyEntry.coffee | 6 ++++ .../components/historyFileEntity.coffee | 34 +++++++++++++++++++ .../history/components/historyFileTree.coffee | 17 ++++++++++ 3 files changed, 57 insertions(+) create mode 100644 services/web/public/coffee/ide/history/components/historyFileEntity.coffee create mode 100644 services/web/public/coffee/ide/history/components/historyFileTree.coffee diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee index 90b607a628..53824c1317 100644 --- a/services/web/public/coffee/ide/history/components/historyEntry.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -13,6 +13,12 @@ define [ if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }" else if projectOp.add? then "#{ projectOp.add.pathname}" else if projectOp.remove? then "#{ projectOp.remove.pathname}" + ctrl.getUserCSSStyle = (user) -> + hue = user?.hue or 100 + if ctrl.entry.inSelection + color : "#FFF" + else + color: "hsl(#{ hue }, 70%, 50%)" return App.component "historyEntry", { diff --git a/services/web/public/coffee/ide/history/components/historyFileEntity.coffee b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee new file mode 100644 index 0000000000..bf99348c62 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee @@ -0,0 +1,34 @@ +define [ + "base" + "ide/file-tree/util/iconTypeFromName" +], (App, iconTypeFromName) -> + # TODO Add arrows in folders + historyFileEntityController = ($scope, $element, $attrs) -> + ctrl = @ + _handleFolderClick = () -> + ctrl.isOpen = !ctrl.isOpen + ctrl.iconClass = _getFolderIcon() + _handleFileClick = () -> + ctrl.historyFileTreeController.handleEntityClick ctrl.fileEntity + _getFolderIcon = () -> + if ctrl.isOpen then "fa-folder-open" else "fa-folder" + ctrl.$onInit = () -> + if ctrl.fileEntity.type == "folder" + ctrl.isOpen = true + ctrl.iconClass = _getFolderIcon() + ctrl.handleClick = _handleFolderClick + else + ctrl.iconClass = "fa-#{ iconTypeFromName(ctrl.fileEntity.name) }" + ctrl.handleClick = _handleFileClick + $scope.$watch (() -> ctrl.historyFileTreeController.selectedPathname), (newPathname) -> + ctrl.isSelected = ctrl.fileEntity.pathname == newPathname + return + + App.component "historyFileEntity", { + require: + historyFileTreeController: "^historyFileTree" + bindings: + fileEntity: "<" + controller: historyFileEntityController + templateUrl: "historyFileEntityTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyFileTree.coffee b/services/web/public/coffee/ide/history/components/historyFileTree.coffee new file mode 100644 index 0000000000..e8daf13af9 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyFileTree.coffee @@ -0,0 +1,17 @@ +define [ + "base" +], (App) -> + historyFileTreeController = ($scope, $element, $attrs) -> + ctrl = @ + ctrl.handleEntityClick = (file) -> + ctrl.onSelectedFileChange file: file + return + + App.component "historyFileTree", { + bindings: + fileTree: "<" + selectedPathname: "<" + onSelectedFileChange: "&" + controller: historyFileTreeController + templateUrl: "historyFileTreeTpl" + } \ No newline at end of file From a716f9ccd3508dce1acd5fb1979c93cfc97bc9b1 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:12:47 +0100 Subject: [PATCH 029/135] Integrate history file tree in the UI. --- .../file-tree/util/iconTypeFromName.coffee | 13 ++++++ .../HistoryV2FileTreeController.coffee | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee create mode 100644 services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee diff --git a/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee new file mode 100644 index 0000000000..01c11f395a --- /dev/null +++ b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee @@ -0,0 +1,13 @@ +define [], () -> + return iconTypeFromName = (name) -> + ext = name.split(".").pop()?.toLowerCase() + if ext in ["png", "pdf", "jpg", "jpeg", "gif"] + return "image" + else if ext in ["csv", "xls", "xlsx"] + return "table" + else if ext in ["py", "r"] + return "file-text" + else if ext in ['bib'] + return 'book' + else + return "file" \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee new file mode 100644 index 0000000000..fc9caaa6e4 --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee @@ -0,0 +1,45 @@ +define [ + "base" +], (App) -> + + App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) -> + $scope.currentFileTree = [] + _selectedDefaultPathname = (files) -> + # TODO: Improve heuristic to determine the default pathname to show. + if files? and files.length > 0 + mainFile = files.find (file) -> /main\.tex$/.test file.pathname + if mainFile? + mainFile.pathname + else + files[0].pathname + + $scope.handleFileSelection = (file) -> + $scope.history.selection.pathname = file.pathname + + $scope.$watch 'history.files', (files) -> + $scope.currentFileTree = _.reduce files, reducePathsToTree, [] + $scope.history.selection.pathname = _selectedDefaultPathname(files) + + reducePathsToTree = (currentFileTree, fileObject) -> + filePathParts = fileObject.pathname.split "/" + currentFileTreeLocation = currentFileTree + for pathPart, index in filePathParts + isFile = index == filePathParts.length - 1 + if isFile + fileTreeEntity = + name: pathPart + pathname: fileObject.pathname + type: "file" + operation: fileObject.operation || "edited" + currentFileTreeLocation.push fileTreeEntity + else + fileTreeEntity = _.find currentFileTreeLocation, (entity) => entity.name == pathPart + if !fileTreeEntity? + fileTreeEntity = + name: pathPart + type: "folder" + children: [] + currentFileTreeLocation.push fileTreeEntity + currentFileTreeLocation = fileTreeEntity.children + return currentFileTree + ] \ No newline at end of file From a501e7dc855bc2f6bbbd84a9be998ab75de4761c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:13:16 +0100 Subject: [PATCH 030/135] History file tree styles. --- .../stylesheets/app/editor/history-v2.less | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 6232caf180..4741399007 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -64,6 +64,55 @@ } } +.history-file-tree { +} + .history-file-entity-wrapper { + color: #FFF; + margin-left: (@line-height-computed / 2); + } + .history-file-entity-link { + display: block; + position: relative; + color: @file-tree-item-color; + line-height: @file-tree-line-height; + &:hover { + background-color: @file-tree-item-hover-bg; + color: @file-tree-item-color; + text-decoration: none; + } + &:focus { + color: @file-tree-item-color; + outline: none; + text-decoration: none; + } + &:hover when (@is-overleaf = true) { + .fake-full-width-bg(@file-tree-item-hover-bg); + } + } + .history-file-entity-link-selected { + background-color: @file-tree-item-selected-bg; + font-weight: bold; + padding-right: 32px; + .fake-full-width-bg(@file-tree-item-selected-bg); + &:hover { + background-color: @file-tree-item-hover-bg; + } + } + .history-file-entity-icon { + color: @file-tree-item-icon-color; + font-size: 14px; + margin-right: .5em; + .history-file-entity-link-selected & { + color: #FFF; + } + } + .history-file-entity-name { + display: block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } // @changesListWidth: 250px; // @changesListPadding: @line-height-computed / 2; From 81c93e11d018957b7b586a44ddd388df24181d8c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:13:34 +0100 Subject: [PATCH 031/135] History file tree integration with the backend. --- .../project/editor/history-file-tree.pug | 53 ++++++++++++++----- .../project/editor/history/entriesListV2.pug | 9 ++-- .../FileTreeEntityController.coffee | 16 ++---- .../coffee/ide/history/HistoryManager.coffee | 2 - .../ide/history/HistoryV2Manager.coffee | 40 ++++++++++++-- .../controllers/HistoryListController.coffee | 2 +- .../HistoryV2ListController.coffee | 2 +- 7 files changed, 85 insertions(+), 39 deletions(-) diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug index 3356dc2249..fd6dc7c205 100644 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ b/services/web/app/views/project/editor/history-file-tree.pug @@ -1,17 +1,46 @@ -aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view == 'history' && history.isV2").full-size +aside.file-tree.file-tree-history.full-size( + ng-controller="HistoryV2FileTreeController" + ng-if="ui.view == 'history' && history.isV2" +) .toolbar.toolbar-filetree span Modified files .file-tree-inner - ul.list-unstyled.file-tree-list - li( - ng-repeat="(pathname, doc) in history.selection.docs" - ng-class="{ 'selected': history.selection.pathname == pathname }" + history-file-tree( + file-tree="currentFileTree" + selected-pathname="history.selection.pathname" + on-selected-file-change="handleFileSelection(file)" + ) + +script(type="text/ng-template", id="historyFileTreeTpl") + .history-file-tree + history-file-entity( + ng-repeat="fileEntity in $ctrl.fileTree" + file-entity="fileEntity" + ) + +script(type="text/ng-template", id="historyFileEntityTpl") + .history-file-entity-wrapper + a.history-file-entity-link( + href + ng-click="$ctrl.handleClick()" + ng-class="{ 'history-file-entity-link-selected': $ctrl.isSelected }" + ) + span.history-file-entity-name + i.history-file-entity-icon.history-file-entity-icon-folder-state.fa.fa-fw( + ng-class="{\ + 'fa-chevron-down': ($ctrl.fileEntity.type === 'folder' && $ctrl.isOpen),\ + 'fa-chevron-right': ($ctrl.fileEntity.type === 'folder' && !$ctrl.isOpen)\ + }" + ) + i.history-file-entity-icon.fa( + ng-class="$ctrl.iconClass" + ) + | {{ ::$ctrl.fileEntity.name }} + div( + ng-show="$ctrl.isOpen" + ) + history-file-entity( + ng-repeat="childEntity in $ctrl.fileEntity.children" + file-entity="childEntity" ) - .entity - .entity-name.entity-name-history( - ng-click="history.selection.pathname = pathname", - ng-class="{ 'deleted': !!doc.deletedAtV }" - ) - i.fa.fa-fw.fa-pencil - span {{ pathname }} diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index 746beb67f1..2414deb5eb 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -12,7 +12,6 @@ aside.change-list( on-entry-select="handleEntrySelect(selectedEntry)" ) - script(type="text/ng-template", id="historyEntriesListTpl") .history-entries( infinite-scroll="$ctrl.loadEntries()" @@ -87,17 +86,17 @@ script(type="text/ng-template", id="historyEntryTpl") li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users") span.name( ng-if="::update_user && update_user.id != $ctrl.currentUser.id" - ng-style="::{'color': 'hsl({{ update_user.hue }}, 70%, 50%)'}" + ng-style="$ctrl.getUserCSSStyle(update_user);" ) {{ ::$ctrl.displayName(update_user) }} span.name( ng-if="::update_user && update_user.id == $ctrl.currentUser.id" - ng-style="::{'color': 'hsl({{ update_user.hue }}, 70%, 50%)'}" + ng-style="$ctrl.getUserCSSStyle(update_user);" ) You span.name( ng-if="::update_user == null" - ng-style="::{'color': 'hsl(100, 70%, 50%)'}" + ng-style="$ctrl.getUserCSSStyle(update_user);" ) #{translate("anonymous")} li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0") span.name( - ng-style="::{'color': 'hsl(100, 70%, 50%)'}" + ng-style="$ctrl.getUserCSSStyle();" ) #{translate("anonymous")} \ No newline at end of file diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee index 735d065cd8..0dcbac31c1 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee @@ -1,6 +1,7 @@ define [ "base" -], (App) -> + "ide/file-tree/util/iconTypeFromName" +], (App, iconTypeFromName) -> App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) -> $scope.select = (e) -> if e.ctrlKey or e.metaKey @@ -70,18 +71,7 @@ define [ $scope.$on "delete:selected", () -> $scope.openDeleteModal() if $scope.entity.selected - $scope.iconTypeFromName = (name) -> - ext = name.split(".").pop()?.toLowerCase() - if ext in ["png", "pdf", "jpg", "jpeg", "gif"] - return "image" - else if ext in ["csv", "xls", "xlsx"] - return "table" - else if ext in ["py", "r"] - return "file-text" - else if ext in ['bib'] - return 'book' - else - return "file" + $scope.iconTypeFromName = iconTypeFromName ] App.controller "DeleteEntityModalController", [ diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index e79f0ad116..8c77d97965 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -3,9 +3,7 @@ define [ "ide/colors/ColorManager" "ide/history/util/displayNameForUser" "ide/history/controllers/HistoryListController" - "ide/history/controllers/HistoryV2ListController" "ide/history/controllers/HistoryDiffController" - "ide/history/controllers/HistoryV2DiffController" "ide/history/directives/infiniteScroll" ], (moment, ColorManager, displayNameForUser) -> class HistoryManager diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 16861d5848..7173cf5b51 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -3,16 +3,19 @@ define [ "ide/colors/ColorManager" "ide/history/util/displayNameForUser" "ide/history/util/HistoryViewModes" - "ide/history/controllers/HistoryListController" - "ide/history/controllers/HistoryDiffController" + "ide/history/controllers/HistoryV2ListController" + "ide/history/controllers/HistoryV2DiffController" + "ide/history/controllers/HistoryV2FileTreeController" "ide/history/directives/infiniteScroll" "ide/history/components/historyEntriesList" "ide/history/components/historyEntry" + "ide/history/components/historyFileTree" + "ide/history/components/historyFileEntity" ], (moment, ColorManager, displayNameForUser, HistoryViewModes) -> class HistoryManager constructor: (@ide, @$scope) -> @reset() - + @$scope.toggleHistory = () => if @$scope.ui.view == "history" @hide() @@ -26,6 +29,9 @@ define [ # @$scope.$watch "history.selection.pathname", () => # @reloadDiff() + @$scope.$watch "history.selection.pathname", (pathname) => + if pathname? + @loadFileAtPointInTime() show: () -> @$scope.ui.view = "history" @@ -50,16 +56,28 @@ define [ toV: null } } + files: [] diff: null } restoreFile: (version, pathname) -> url = "/project/#{@$scope.project_id}/restore_file" + @ide.$http.post(url, { version, pathname, _csrf: window.csrfToken }) + loadFileTreeForUpdate: (update) -> + {fromV, toV} = update + url = "/project/#{@$scope.project_id}/filetree/diff" + query = [ "from=#{toV}", "to=#{toV}" ] + url += "?" + query.join("&") + @ide.$http + .get(url) + .then (response) => + @$scope.history.files = response.data.diff + MAX_RECENT_UPDATES_TO_SELECT: 5 autoSelectRecentUpdates: () -> return if @$scope.history.updates.length == 0 @@ -76,8 +94,7 @@ define [ autoSelectLastUpdate: () -> return if @$scope.history.updates.length == 0 - @$scope.history.updates[0].selectedTo = true - @$scope.history.updates[0].selectedFrom = true + @selectUpdate @$scope.history.updates[0] selectUpdate: (update) -> selectedUpdateIndex = @$scope.history.updates.indexOf update @@ -88,6 +105,7 @@ define [ update.selectedFrom = false @$scope.history.updates[selectedUpdateIndex].selectedTo = true @$scope.history.updates[selectedUpdateIndex].selectedFrom = true + @loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex] BATCH_SIZE: 10 fetchNextBatchOfUpdates: () -> @@ -105,6 +123,18 @@ define [ @$scope.history.atEnd = true @$scope.history.loading = false + loadFileAtPointInTime: () -> + pathname = @$scope.history.selection.pathname + toV = @$scope.history.selection.updates[0].toV + url = "/project/#{@$scope.project_id}/diff" + query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"] + url += "?" + query.join("&") + @ide.$http + .get(url) + .then (response) => + { data } = response + .catch () -> + reloadDiff: () -> diff = @$scope.history.diff {updates} = @$scope.history.selection diff --git a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee index f16cace816..4b0786d259 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee @@ -5,7 +5,7 @@ define [ App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) -> $scope.hoveringOverListSelectors = false - + $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee index 4ea91d7cc8..823461d16a 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -5,7 +5,7 @@ define [ App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) -> $scope.hoveringOverListSelectors = false - + $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() From 73184c063e23a105177af1059209c76e7a12bd0b Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 22 May 2018 11:36:35 +0100 Subject: [PATCH 032/135] Be more specific about the source-file-not-found error case --- .../Features/LinkedFiles/ProjectFileAgent.coffee | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 514792eb64..908f53a7ea 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -32,12 +32,12 @@ BadDataError = (message) -> BadDataError.prototype.__proto__ = Error.prototype -FileNotFoundError = (message) -> +SourceFileNotFoundError = (message) -> error = new Error(message) error.name = 'BadData' - error.__proto__ = FileNotFoundError.prototype + error.__proto__ = SourceFileNotFoundError.prototype return error -FileNotFoundError.prototype.__proto__ = Error.prototype +SourceFileNotFoundError.prototype.__proto__ = Error.prototype module.exports = ProjectFileAgent = @@ -68,7 +68,7 @@ module.exports = ProjectFileAgent = }, (err, entity, type) -> if err? if err.toString().match(/^not found.*/) - err = new FileNotFoundError() + err = new SourceFileNotFoundError() return callback(err) ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback @@ -92,8 +92,8 @@ module.exports = ProjectFileAgent = res.status(400).send("The submitted data is not valid") else if error instanceof BadEntityTypeError res.status(400).send("The file is the wrong type") - else if error instanceof FileNotFoundError - res.status(404).send("File not found") + else if error instanceof SourceFileNotFoundError + res.status(404).send("Source file not found") else next(error) next() From e34131ed45bc09894afb00e3aba4cbbceffdc622 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 22 May 2018 15:01:51 +0100 Subject: [PATCH 033/135] Add acceptance test for project linked files --- services/web/docker-compose.yml | 1 + .../acceptance/coffee/LinkedFilesTests.coffee | 83 ++++++++++++++++++- .../acceptance/coffee/helpers/User.coffee | 12 +++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 5a668bc4a3..410462a745 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -17,6 +17,7 @@ services: PROJECT_HISTORY_ENABLED: 'true' ENABLED_LINKED_FILE_TYPES: 'url' LINKED_URL_PROXY: 'http://localhost:6543' + ENABLED_LINKED_FILE_TYPES: 'url,project_file' depends_on: - redis - mongo diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee index 9ca7ecae42..ad79a4802c 100644 --- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee +++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee @@ -27,6 +27,81 @@ describe "LinkedFiles", -> @owner.login -> mkdirp Settings.path.dumpFolder, done + describe "creating a project linked file", -> + before (done) -> + @source_doc_name = 'test.txt' + async.series [ + (cb) => + @owner.createProject 'plf-test-one', {template: 'blank'}, (error, project_id) => + @project_one_id = project_id + cb(error) + (cb) => + @owner.getProject @project_one_id, (error, project) => + @project_one = project + @project_one_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createProject 'plf-test-two', {template: 'blank'}, (error, project_id) => + @project_two_id = project_id + cb(error) + (cb) => + @owner.getProject @project_two_id, (error, project) => + @project_two = project + @project_two_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + @source_doc_name, + (error, doc_id) => + @source_doc_id = doc_id + cb(error) + ], done + + it 'should import a file from the source project', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + source_project_display_name: "Project Two" + }, (error, response, body) => + new_file_id = body.new_file_id + @existing_file_id = new_file_id + expect(new_file_id).to.exist + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.name).to.equal('test-link.txt') + done() + + it 'should refresh the file', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + source_project_display_name: "Project Two" + }, (error, response, body) => + new_file_id = body.new_file_id + expect(new_file_id).to.exist + expect(new_file_id).to.not.equal @existing_file_id + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.name).to.equal('test-link.txt') + done() + describe "creating a URL based linked file", -> before (done) -> @owner.createProject "url-linked-files-project", {template: "blank"}, (error, project_id) => @@ -50,7 +125,7 @@ describe "LinkedFiles", -> name: 'url-test-file-1' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[0] @@ -76,7 +151,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.request.post { url: "/project/#{@project_id}/linked_file", json: @@ -88,7 +163,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[1] @@ -168,7 +243,7 @@ describe "LinkedFiles", -> name: 'url-test-file-6' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = _.find project.rootFolder[0].fileRefs, (file) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 7d8e9086d4..a0c595a694 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -137,6 +137,18 @@ class User return callback(err) callback(null) + createDocInProject: (project_id, parent_folder_id, name, callback=(error, doc_id)->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: "/project/#{project_id}/doc", + json: { + name: name, + parent_folder_id: parent_folder_id + } + }, (error, response, body) => + callback(null, body._id) + addUserToProject: (project_id, user, privileges, callback = (error, user) ->) -> if privileges == 'readAndWrite' updateOp = {$addToSet: {collaberator_refs: user._id.toString()}} From f4f3a4375ba703399d56e86b7c0ff1dcc2d8e3f0 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 22 May 2018 15:40:57 +0100 Subject: [PATCH 034/135] Add history toolbar (just scaffolding); show files at point in time. --- services/web/app/views/project/editor.pug | 3 +++ .../project/editor/history-file-tree.pug | 7 ++---- .../web/app/views/project/editor/history.pug | 2 +- .../{diffPanelV2.pug => previewPanelV2.pug} | 22 +++++++++++++++++-- .../project/editor/history/toolbarV2.pug | 4 ++++ .../ide/history/HistoryV2Manager.coffee | 9 +++++--- .../web/public/stylesheets/app/editor.less | 6 ++++- .../stylesheets/app/editor/history-v2.less | 19 +++++++++++++++- .../stylesheets/app/editor/history.less | 15 ++++++++----- .../stylesheets/core/_common-variables.less | 1 + 10 files changed, 69 insertions(+), 19 deletions(-) rename services/web/app/views/project/editor/history/{diffPanelV2.pug => previewPanelV2.pug} (76%) create mode 100644 services/web/app/views/project/editor/history/toolbarV2.pug diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index a9ba703a32..45600c4a8f 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -57,9 +57,12 @@ block content include ./editor/share != moduleIncludes("publish:body", locals) + include ./editor/history/toolbarV2.pug + main#ide-body( ng-cloak, role="main", + ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2) }", layout="main", ng-hide="state.loading", resize-on="layout:chat:resize", diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug index fd6dc7c205..1944974ce2 100644 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ b/services/web/app/views/project/editor/history-file-tree.pug @@ -1,11 +1,8 @@ -aside.file-tree.file-tree-history.full-size( +aside.file-tree.full-size( ng-controller="HistoryV2FileTreeController" ng-if="ui.view == 'history' && history.isV2" ) - .toolbar.toolbar-filetree - span Modified files - - .file-tree-inner + .history-file-tree-inner history-file-tree( file-tree="currentFileTree" selected-pathname="history.selection.pathname" diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index f237aff89e..a7a52d2927 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -44,7 +44,7 @@ div#history(ng-show="ui.view == 'history'") include ./history/entriesListV2 include ./history/diffPanelV1 - include ./history/diffPanelV2 + include ./history/previewPanelV2 script(type="text/ng-template", id="historyRestoreDiffModalTemplate") .modal-header diff --git a/services/web/app/views/project/editor/history/diffPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug similarity index 76% rename from services/web/app/views/project/editor/history/diffPanelV2.pug rename to services/web/app/views/project/editor/history/previewPanelV2.pug index 415d587155..704cc2e0a7 100644 --- a/services/web/app/views/project/editor/history/diffPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -1,4 +1,7 @@ -.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController") +.diff-panel.full-size( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE" + ng-controller="HistoryV2DiffController" +) .diff( ng-if="!!history.diff && !history.diff.loading && !history.diff.error", ng-class="{ 'diff-binary': history.diff.binary }" @@ -47,4 +50,19 @@ i.fa.fa-spin.fa-refresh |   #{translate("loading")}... .error-panel(ng-show="history.diff.error") - .alert.alert-danger #{translate("generic_something_went_wrong")} \ No newline at end of file + .alert.alert-danger #{translate("generic_something_went_wrong")} + +.point-in-time-panel.full-size( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" +) + .point-in-time-editor-container( + ng-if="!!history.selectedFile" + ) + .hide-ace-cursor( + ace-editor="history-pointintime", + theme="settings.theme", + font-size="settings.fontSize", + text="history.selectedFile.text", + read-only="true", + resize-on="layout:main:resize", + ) \ No newline at end of file diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug new file mode 100644 index 0000000000..a66fab3ee3 --- /dev/null +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -0,0 +1,4 @@ +.history-toolbar( + ng-if="ui.view == 'history' && history.isV2" +) Browsing project as of  + time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 7173cf5b51..aac9c11367 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -15,7 +15,8 @@ define [ class HistoryManager constructor: (@ide, @$scope) -> @reset() - + @$scope.HistoryViewModes = HistoryViewModes + @$scope.toggleHistory = () => if @$scope.ui.view == "history" @hide() @@ -57,7 +58,8 @@ define [ } } files: [] - diff: null + diff: null # When history.viewMode == HistoryViewModes.COMPARE + selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME } restoreFile: (version, pathname) -> @@ -132,7 +134,8 @@ define [ @ide.$http .get(url) .then (response) => - { data } = response + @$scope.history.selectedFile = + text : response.data.diff[0].u .catch () -> reloadDiff: () -> diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 46a35c440f..8b07bd9263 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -73,9 +73,13 @@ #ide-body { .full-size; - top: 40px; + top: @ide-body-top-offset; + &.ide-history-open { + top: @ide-body-top-offset + @editor-toolbar-height; + } } + #editor, #editor-rich-text { .full-size; } diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 4741399007..9ff03e55f5 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -1,3 +1,17 @@ +.history-toolbar { + position: absolute; + width: 100%; + top: @ide-body-top-offset; + height: @editor-toolbar-height; + line-height: @editor-toolbar-height; + background-color: @editor-toolbar-bg; + z-index: 1; + color: #FFF; + padding-left: (@line-height-computed / 2); +} + .history-toolbar-time { + font-weight: bold; + } .history-entries { font-size: @history-base-font-size; color: @history-base-color; @@ -64,7 +78,10 @@ } } -.history-file-tree { +.history-file-tree-inner { + .full-size; + overflow-y: auto; + background-color: @file-tree-bg; } .history-file-entity-wrapper { color: #FFF; diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index 68616f6100..ba4e1e142e 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -40,7 +40,8 @@ } } - .diff-panel { + .diff-panel, + .point-in-time-panel { .full-size; margin-right: @changesListWidth; } @@ -59,11 +60,7 @@ .full-size; top: 40px; } - .hide-ace-cursor { - .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { - display: none; - } - } + .diff-deleted { padding: @line-height-computed; } @@ -305,6 +302,12 @@ padding-top: 15px; } +.hide-ace-cursor { + .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { + display: none; + } +} + .editor-dark { #history { aside.change-list { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 3afb428692..543e169b1e 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -888,6 +888,7 @@ @footer-padding : 2em; // Editor header +@ide-body-top-offset : 40px; @toolbar-header-bg-color : transparent; @toolbar-header-shadow : 0 0 2px #ccc; @toolbar-btn-color : @link-color; From bc5769cd736d7e00f061c8014a39f714de3c942c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 22 May 2018 15:56:01 +0100 Subject: [PATCH 035/135] Stub out the ProjectEntityHandler in ProjectController tests --- .../web/test/unit/coffee/Project/ProjectControllerTests.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index ccae21ba70..3b583daa6c 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -67,6 +67,7 @@ describe "ProjectController", -> protectTokens: sinon.stub() @CollaboratorsHandler = userIsTokenMember: sinon.stub().callsArgWith(2, null, false) + @ProjectEntityHandler = {} @Modules = hooks: fire: sinon.stub() @@ -98,6 +99,7 @@ describe "ProjectController", -> "../TokenAccess/TokenAccessHandler": @TokenAccessHandler "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler "../../infrastructure/Modules": @Modules + "./ProjectEntityHandler": @ProjectEntityHandler @projectName = "£12321jkj9ujkljds" @req = From b1c1cdecef10a71a2cda905353891c7431b42b09 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 22 May 2018 16:17:59 +0100 Subject: [PATCH 036/135] Add unit test for ProjectController.userProjectsJson --- .../Project/ProjectControllerTests.coffee | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index 3b583daa6c..223d2beb6b 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -521,7 +521,32 @@ describe "ProjectController", -> @ProjectUpdateHandler.markAsOpened.calledWith(@project_id).should.equal true done() @ProjectController.loadEditor @req, @res - + + describe 'userProjectsJson', -> + beforeEach (done) -> + projects = [ + {archived: true, id: 'a', name: 'A', accessLevel: 'a', somethingElse: 1} + {archived: false, id: 'b', name: 'B', accessLevel: 'b', somethingElse: 1} + {archived: false, id: 'c', name: 'C', accessLevel: 'c', somethingElse: 1} + {archived: false, id: 'd', name: 'D', accessLevel: 'd', somethingElse: 1} + ] + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, []) + @ProjectController._buildProjectList = sinon.stub().returns(projects) + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + done() + + it 'should produce a list of projects', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + projects: [ + {_id: 'b', name: 'B', accessLevel: 'b'}, + {_id: 'c', name: 'C', accessLevel: 'c'}, + {_id: 'd', name: 'D', accessLevel: 'd'} + ] + } + done() + @ProjectController.userProjectsJson @req, @res, @next + describe '_isInPercentageRollout', -> before -> @ids = [ From 4daf062be9fd2b86c571869b5b6b83cbd7c8c4e1 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 22 May 2018 16:40:39 +0100 Subject: [PATCH 037/135] Add unit test for ProjectController.projectEntitiesJson --- .../Features/Project/ProjectController.coffee | 25 +++++----- .../Project/ProjectControllerTests.coffee | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 3a1219afd5..10e32d1746 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -154,21 +154,20 @@ module.exports = ProjectController = projectEntitiesJson: (req, res, next) -> user_id = AuthenticationController.getLoggedInUserId(req) project_id = req.params.Project_id - AuthorizationManager.canUserReadProject user_id, project_id, - null, (err, canRead) -> + AuthorizationManager.canUserReadProject user_id, project_id, null, (err, canRead) -> + return next(err) if err? + return res.sendStatus(403) if !canRead + ProjectGetter.getProject project_id, (err, project) -> return next(err) if err? - return res.status(403) if !canRead - ProjectGetter.getProject project_id, (err, project) -> + ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> return next(err) if err? - ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> - return next(err) if err? - entities = docs.concat(files) - .sort (a, b) -> a.path > b.path # Sort by path ascending - .map (e) -> { - path: e.path, - type: if e.doc? then 'doc' else 'file' - } - res.json({project_id: project_id, entities: entities}) + entities = docs.concat(files) + .sort (a, b) -> a.path > b.path # Sort by path ascending + .map (e) -> { + path: e.path, + type: if e.doc? then 'doc' else 'file' + } + res.json({project_id: project_id, entities: entities}) projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index 223d2beb6b..6483319bab 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -547,6 +547,54 @@ describe "ProjectController", -> done() @ProjectController.userProjectsJson @req, @res, @next + describe 'projectEntitiesJson', -> + beforeEach () -> + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + @req.params = {Project_id: 'abcd'} + @project = { _id: 'abcd' } + @docs = [ + {path: '/things/b.txt', doc: true}, + {path: '/main.tex', doc: true} + ] + @files = [ + {path: '/things/a.txt'} + ] + @ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project) + @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files) + + describe 'when the user can access the project', -> + beforeEach () -> + @AuthorizationManager.canUserReadProject = sinon.stub().callsArgWith(3, null, true) + + it 'should produce a list of entities', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + project_id: 'abcd', + entities: [ + {path: '/main.tex', type: 'doc'}, + {path: '/things/a.txt', type: 'file'}, + {path: '/things/b.txt', type: 'doc'} + ] + } + expect(@ProjectGetter.getProject.callCount).to.equal 1 + expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1 + done() + @ProjectController.projectEntitiesJson @req, @res, @next + + describe 'when the user cannot access the project', -> + beforeEach () -> + @AuthorizationManager.canUserReadProject = sinon.stub().callsArgWith(3, null, false) + + it 'should send a 403 response', (done) -> + @res.json = sinon.stub() + @res.sendStatus = (code) => + expect(code).to.equal 403 + expect(@ProjectGetter.getProject.callCount).to.equal 0 + expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 0 + expect(@res.json.callCount).to.equal 0 + done() + @ProjectController.projectEntitiesJson @req, @res, @next + describe '_isInPercentageRollout', -> before -> @ids = [ From 6a5af88e12458c57138e2908f6d297e66eb19d20 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 11:32:00 +0100 Subject: [PATCH 038/135] Remove stray comment --- services/web/app/coffee/router.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index e7c730af2e..66e359e98f 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -119,7 +119,6 @@ module.exports = class Router webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo - # TODO: check this is the right router for these routes webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), ProjectController.projectEntitiesJson From 295425e791d7d7eb26f5a00715b3fc0ac7581f1f Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 11:34:55 +0100 Subject: [PATCH 039/135] Check that user can read a project on entities-json route --- services/web/app/coffee/router.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 66e359e98f..7b0d11863b 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -120,7 +120,9 @@ module.exports = class Router privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson - webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), ProjectController.projectEntitiesJson + webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanReadProject + ProjectController.projectEntitiesJson webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject From f5dd94ca1d63218a2d69deedc9f9014d65c1d325 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 11:46:37 +0100 Subject: [PATCH 040/135] Remove test code from FileTreeManager --- .../ide/file-tree/FileTreeManager.coffee | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 2cd6ea4031..dfaef74edb 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -26,31 +26,6 @@ define [ @clearMultiSelectedEntities() @$scope.$digest() - # TODO: remove - window._doLinkedFileImportFromProject = (project, path, name) => - parent_folder = @getCurrentFolder() - @ide.$http.post "/project/#{@ide.project_id}/linked_file", { - name: name, - parent_folder_id: parent_folder?.id - provider: 'project_file', - data: { - source_project_id: project, - source_entity_path: path - }, - _csrf: window.csrfToken - } - - # TODO: remove - window._getProjects = () => - @ide.$http.get("/user/projects", { - _csrf: window.csrfToken - }).then (resp) -> console.log(resp.status, resp.data) - - window._getProjectEntities = (project_id) => - @ide.$http.get("/project/#{project_id}/entities", { - _csrf: window.csrfToken - }).then (resp) -> console.log(resp.status, resp.data) - _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder From fb33fc6c30ef295255d5e58d08e870356584ad9f Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 23 May 2018 12:14:27 +0100 Subject: [PATCH 041/135] Add loading indicators; handle binary files; keep selected file across points in time. --- .../project/editor/history-file-tree.pug | 5 ++++ .../project/editor/history/entriesListV2.pug | 3 +- .../project/editor/history/previewPanelV2.pug | 12 ++++++-- .../project/editor/history/toolbarV2.pug | 5 ++-- .../ide/history/HistoryV2Manager.coffee | 15 ++++++++-- .../history/components/historyFileTree.coffee | 1 + .../HistoryV2FileTreeController.coffee | 29 ++++++++++++------- .../stylesheets/app/editor/history-v2.less | 8 +++++ 8 files changed, 61 insertions(+), 17 deletions(-) diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug index 1944974ce2..9d9a784678 100644 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ b/services/web/app/views/project/editor/history-file-tree.pug @@ -7,6 +7,7 @@ aside.file-tree.full-size( file-tree="currentFileTree" selected-pathname="history.selection.pathname" on-selected-file-change="handleFileSelection(file)" + is-loading="history.loadingFileTree" ) script(type="text/ng-template", id="historyFileTreeTpl") @@ -14,7 +15,11 @@ script(type="text/ng-template", id="historyFileTreeTpl") history-file-entity( ng-repeat="fileEntity in $ctrl.fileTree" file-entity="fileEntity" + ng-show="!$ctrl.isLoading" ) + .loading(ng-show="$ctrl.isLoading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... script(type="text/ng-template", id="historyFileEntityTpl") .history-file-entity-wrapper diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index 2414deb5eb..6a9c75e08f 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -24,8 +24,9 @@ script(type="text/ng-template", id="historyEntriesListTpl") entry="entry" current-user="$ctrl.currentUser" on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })" + ng-show="!$ctrl.isLoading" ) - .loading(ng-show="history.loading") + .loading(ng-show="$ctrl.isLoading") i.fa.fa-spin.fa-refresh |    #{translate("loading")}... diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index 704cc2e0a7..597dc8b0bf 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -56,13 +56,21 @@ ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" ) .point-in-time-editor-container( - ng-if="!!history.selectedFile" + ng-if="!!history.selectedFile && !history.selectedFile.loading && !history.selectedFile.error" ) .hide-ace-cursor( + ng-if="!history.selectedFile.binary" ace-editor="history-pointintime", theme="settings.theme", font-size="settings.fontSize", text="history.selectedFile.text", read-only="true", resize-on="layout:main:resize", - ) \ No newline at end of file + ) + .alert.alert-info(ng-if="history.selectedFile.binary") + | We're still working on showing image and binary changes, sorry. Stay tuned! + .loading-panel(ng-show="history.selectedFile.loading") + i.fa.fa-spin.fa-refresh + |   #{translate("loading")}... + .error-panel(ng-show="history.selectedFile.error") + .alert.alert-danger #{translate("generic_something_went_wrong")} diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index a66fab3ee3..58b94dcaf2 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -1,4 +1,5 @@ .history-toolbar( ng-if="ui.view == 'history' && history.isV2" -) Browsing project as of  - time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} \ No newline at end of file +) + span(ng-show="!history.loadingFileTree") Browsing project as of  + time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index aac9c11367..1bc533d665 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -30,6 +30,7 @@ define [ # @$scope.$watch "history.selection.pathname", () => # @reloadDiff() + @$scope.$watch "history.selection.pathname", (pathname) => if pathname? @loadFileAtPointInTime() @@ -75,10 +76,14 @@ define [ url = "/project/#{@$scope.project_id}/filetree/diff" query = [ "from=#{toV}", "to=#{toV}" ] url += "?" + query.join("&") + @$scope.history.loadingFileTree = true + @$scope.history.selectedFile = null + @$scope.history.selection.pathname = null @ide.$http .get(url) .then (response) => @$scope.history.files = response.data.diff + @$scope.history.loadingFileTree = false MAX_RECENT_UPDATES_TO_SELECT: 5 autoSelectRecentUpdates: () -> @@ -115,6 +120,7 @@ define [ if @$scope.history.nextBeforeTimestamp? url += "&before=#{@$scope.history.nextBeforeTimestamp}" @$scope.history.loading = true + @$scope.history.loadingFileTree = true @ide.$http .get(url) .then (response) => @@ -131,11 +137,16 @@ define [ url = "/project/#{@$scope.project_id}/diff" query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"] url += "?" + query.join("&") + @$scope.history.selectedFile = + loading: true @ide.$http .get(url) .then (response) => - @$scope.history.selectedFile = - text : response.data.diff[0].u + {text, binary} = @_parseDiff(response.data.diff) + @$scope.history.selectedFile.binary = binary + @$scope.history.selectedFile.text = text + @$scope.history.selectedFile.loading = false + console.log @$scope.history.selectedFile .catch () -> reloadDiff: () -> diff --git a/services/web/public/coffee/ide/history/components/historyFileTree.coffee b/services/web/public/coffee/ide/history/components/historyFileTree.coffee index e8daf13af9..7e3c636470 100644 --- a/services/web/public/coffee/ide/history/components/historyFileTree.coffee +++ b/services/web/public/coffee/ide/history/components/historyFileTree.coffee @@ -12,6 +12,7 @@ define [ fileTree: "<" selectedPathname: "<" onSelectedFileChange: "&" + isLoading: "<" controller: historyFileTreeController templateUrl: "historyFileTreeTpl" } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee index fc9caaa6e4..47f5c07c4c 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee @@ -3,24 +3,33 @@ define [ ], (App) -> App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) -> + _previouslySelectedPathname = null $scope.currentFileTree = [] - _selectedDefaultPathname = (files) -> - # TODO: Improve heuristic to determine the default pathname to show. - if files? and files.length > 0 - mainFile = files.find (file) -> /main\.tex$/.test file.pathname + + _pathnameExistsInFiles = (pathname, files) -> + _.any files, (file) -> file.pathname == pathname + + _getSelectedDefaultPathname = (files) -> + selectedPathname = null + if _previouslySelectedPathname? and _pathnameExistsInFiles _previouslySelectedPathname, files + selectedPathname = _previouslySelectedPathname + else + mainFile = _.find files, (file) -> /main\.tex$/.test file.pathname if mainFile? - mainFile.pathname + selectedPathname = _previouslySelectedPathname = mainFile.pathname else - files[0].pathname + selectedPathname = _previouslySelectedPathname = files[0].pathname + return selectedPathname $scope.handleFileSelection = (file) -> - $scope.history.selection.pathname = file.pathname + $scope.history.selection.pathname = _previouslySelectedPathname = file.pathname $scope.$watch 'history.files', (files) -> - $scope.currentFileTree = _.reduce files, reducePathsToTree, [] - $scope.history.selection.pathname = _selectedDefaultPathname(files) + if files? and files.length > 0 + $scope.currentFileTree = _.reduce files, _reducePathsToTree, [] + $scope.history.selection.pathname = _getSelectedDefaultPathname(files) - reducePathsToTree = (currentFileTree, fileObject) -> + _reducePathsToTree = (currentFileTree, fileObject) -> filePathParts = fileObject.pathname.split "/" currentFileTreeLocation = currentFileTree for pathPart, index in filePathParts diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 9ff03e55f5..478f939d71 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -12,6 +12,7 @@ .history-toolbar-time { font-weight: bold; } + .history-entries { font-size: @history-base-font-size; color: @history-base-color; @@ -82,6 +83,13 @@ .full-size; overflow-y: auto; background-color: @file-tree-bg; + + .loading { + color: #FFF; + font-size: @history-base-font-size; + text-align: center; + font-family: @font-family-serif; + } } .history-file-entity-wrapper { color: #FFF; From 561b62f7db26eb1f804173c8ceadba9f6cdd6f68 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 12:37:42 +0100 Subject: [PATCH 042/135] Add a hidden 'Test Controls' section to the left-menu. This is to be used for hidden features that are not yet ready to ship, and would otherwise be hidden behind a console command. Append `?tc=true` to the project url to reveal this panel. --- .../Features/Project/ProjectController.coffee | 1 + .../web/app/views/project/editor/left-menu.pug | 18 ++++++++++++++++++ services/web/public/coffee/ide.coffee | 1 + .../controllers/TestControlsController.coffee | 14 ++++++++++++++ .../coffee/ide/test-controls/index.coffee | 3 +++ 5 files changed, 37 insertions(+) create mode 100644 services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee create mode 100644 services/web/public/coffee/ide/test-controls/index.coffee diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 10e32d1746..cafcc8153a 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -344,6 +344,7 @@ module.exports = ProjectController = maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display showRichText: req.query?.rt == 'true' + showTestControls: req.query?.tc == 'true' showPublishModal: req.query?.pm == 'true' timer.done() diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index ed6ef85b44..d85a45723b 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -62,6 +62,23 @@ aside#left-menu.full-size( != moduleIncludes("editorLeftMenu:editing_services", locals) + if showTestControls + h4 Test Controls + ul.list-unstyled.nav(ng-controller="TestControlsController") + li + a(href="#" ng-click="richText()") + i.fa.fa-exclamation.fa-fw + | Rich Text + li + a(href="#" ng-click="openProjectLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | Project-Linked-File Modal + li + a(href="#" ng-click="openLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | URL-Linked-File Modal + + h4(ng-show="!anonymous") #{translate("settings")} form.settings(ng-controller="SettingsController", ng-show="!anonymous") .containter-fluid @@ -179,6 +196,7 @@ aside#left-menu.full-size( option(value="pdfjs") #{translate("built_in")} option(value="native") #{translate("native")} + h4 #{translate("hotkeys")} ul.list-unstyled.nav li(ng-controller="HotkeysController") diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 7c8602eb76..4f2d914135 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -18,6 +18,7 @@ define [ "ide/chat/index" "ide/clone/index" "ide/hotkeys/index" + "ide/test-controls/index" "ide/wordcount/index" "ide/directives/layout" "ide/directives/validFile" diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee new file mode 100644 index 0000000000..65378a2cde --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee @@ -0,0 +1,14 @@ +define [ + "base" + "ace/ace" +], (App) -> + App.controller "TestControlsController", ($scope) -> + + $scope.openProjectLinkedFileModal = () -> + window.openProjectLinkedFileModal() + + $scope.openLinkedFileModal = () -> + window.openLinkedFileModal() + + $scope.richText = () -> + window.location.href = window.location.toString() + '&rt=true' diff --git a/services/web/public/coffee/ide/test-controls/index.coffee b/services/web/public/coffee/ide/test-controls/index.coffee new file mode 100644 index 0000000000..d60d9e1a01 --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/index.coffee @@ -0,0 +1,3 @@ +define [ + "ide/test-controls/controllers/TestControlsController" +], () -> From ba9143fc3c0edca104a216cfaeae30139f98eb1a Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 12:55:49 +0100 Subject: [PATCH 043/135] Show test-controls for admin users by default --- .../web/app/coffee/Features/Project/ProjectController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index cafcc8153a..3a47690b20 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -344,7 +344,7 @@ module.exports = ProjectController = maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display showRichText: req.query?.rt == 'true' - showTestControls: req.query?.tc == 'true' + showTestControls: req.query?.tc == 'true' || user.isAdmin showPublishModal: req.query?.pm == 'true' timer.done() From 78f87c0ecfb2abfc75fceb5d4afa58cdd36450e5 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 23 May 2018 15:02:45 +0100 Subject: [PATCH 044/135] Add acceptance test for the project-list and project-entities endpoints --- .../acceptance/coffee/LinkedFilesTests.coffee | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee index ad79a4802c..34079de8d1 100644 --- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee +++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee @@ -56,8 +56,45 @@ describe "LinkedFiles", -> (error, doc_id) => @source_doc_id = doc_id cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + 'some-harmless-doc.txt', + (error, doc_id) => + cb(error) ], done + it 'should produce a list of the users projects', (done) -> + @owner.request.get { + url: "/user/projects", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + projects: [ + { _id: @project_one_id, name: 'plf-test-one', accessLevel: 'owner' }, + { _id: @project_two_id, name: 'plf-test-two', accessLevel: 'owner' } + ] + } + done() + + it 'should produce a list of entities in the project', (done) -> + @owner.request.get { + url: "/project/#{@project_two_id}/entities", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + project_id: @project_two_id, + entities: [ + { path: '/main.tex', type: 'doc' }, + { path: '/some-harmless-doc.txt', type: 'doc' }, + { path: '/test.txt', type: 'doc' } + ] + } + done() + + it 'should import a file from the source project', (done) -> @owner.request.post { url: "/project/#{@project_one_id}/linked_file", From 3181f624a7d99665739ac4820b3e6201e338557b Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 10:22:17 +0100 Subject: [PATCH 045/135] Remove obsolete auth check --- .../Features/Project/ProjectController.coffee | 21 ++++----- .../Project/ProjectControllerTests.coffee | 46 ++++++------------- 2 files changed, 23 insertions(+), 44 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 10e32d1746..201b9830af 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -154,20 +154,17 @@ module.exports = ProjectController = projectEntitiesJson: (req, res, next) -> user_id = AuthenticationController.getLoggedInUserId(req) project_id = req.params.Project_id - AuthorizationManager.canUserReadProject user_id, project_id, null, (err, canRead) -> + ProjectGetter.getProject project_id, (err, project) -> return next(err) if err? - return res.sendStatus(403) if !canRead - ProjectGetter.getProject project_id, (err, project) -> + ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> return next(err) if err? - ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> - return next(err) if err? - entities = docs.concat(files) - .sort (a, b) -> a.path > b.path # Sort by path ascending - .map (e) -> { - path: e.path, - type: if e.doc? then 'doc' else 'file' - } - res.json({project_id: project_id, entities: entities}) + entities = docs.concat(files) + .sort (a, b) -> a.path > b.path # Sort by path ascending + .map (e) -> { + path: e.path, + type: if e.doc? then 'doc' else 'file' + } + res.json({project_id: project_id, entities: entities}) projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index 6483319bab..32e1c4953a 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -562,38 +562,20 @@ describe "ProjectController", -> @ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project) @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files) - describe 'when the user can access the project', -> - beforeEach () -> - @AuthorizationManager.canUserReadProject = sinon.stub().callsArgWith(3, null, true) - - it 'should produce a list of entities', (done) -> - @res.json = (data) => - expect(data).to.deep.equal { - project_id: 'abcd', - entities: [ - {path: '/main.tex', type: 'doc'}, - {path: '/things/a.txt', type: 'file'}, - {path: '/things/b.txt', type: 'doc'} - ] - } - expect(@ProjectGetter.getProject.callCount).to.equal 1 - expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1 - done() - @ProjectController.projectEntitiesJson @req, @res, @next - - describe 'when the user cannot access the project', -> - beforeEach () -> - @AuthorizationManager.canUserReadProject = sinon.stub().callsArgWith(3, null, false) - - it 'should send a 403 response', (done) -> - @res.json = sinon.stub() - @res.sendStatus = (code) => - expect(code).to.equal 403 - expect(@ProjectGetter.getProject.callCount).to.equal 0 - expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 0 - expect(@res.json.callCount).to.equal 0 - done() - @ProjectController.projectEntitiesJson @req, @res, @next + it 'should produce a list of entities', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + project_id: 'abcd', + entities: [ + {path: '/main.tex', type: 'doc'}, + {path: '/things/a.txt', type: 'file'}, + {path: '/things/b.txt', type: 'doc'} + ] + } + expect(@ProjectGetter.getProject.callCount).to.equal 1 + expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1 + done() + @ProjectController.projectEntitiesJson @req, @res, @next describe '_isInPercentageRollout', -> before -> From 1cbc90149212eec437e297456d24c64a1e910421 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:29:37 +0100 Subject: [PATCH 046/135] Add a `checkAuth` function to linked-file agents --- .../LinkedFiles/LinkedFilesController.coffee | 17 +++++----- .../LinkedFiles/ProjectFileAgent.coffee | 31 +++++++++++-------- .../Features/LinkedFiles/UrlAgent.coffee | 3 ++ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index c9edeefe32..67acdcfc86 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -23,11 +23,14 @@ module.exports = LinkedFilesController = { linkedFileData = Agent.sanitizeData(data) linkedFileData.provider = provider - Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> - if error? - logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' - return Agent.handleError(error, req, res, next) - EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> - return next(error) if error? - res.json(new_file_id: file._id) # created + Agent.checkAuth project_id, data, user_id, (err, allowed) -> + return next(err) if err? + return ses.sendStatus(403) if !allowed + Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> + if error? + logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' + return Agent.handleError(error, req, res, next) + EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> + return next(error) if error? + res.json(new_file_id: file._id) # created } diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 908f53a7ea..55f20ed9a7 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -52,25 +52,30 @@ module.exports = ProjectFileAgent = !!data.source_project_display_name ) + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) + {source_project_id, source_entity_path} = data + AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) -> + return callback(err) if err? + callback(null, canRead) + writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) -> callback = _.once(callback) if !ProjectFileAgent._validate(data) return callback(new BadDataError()) {source_project_id, source_entity_path} = data - AuthorizationManager.canUserReadProject current_user_id, source_project_id, - null, (err, canRead) -> - return callback(err) if err? - return callback(new AccessDeniedError()) if !canRead - ProjectLocator.findElementByPath { - project_id: source_project_id, - path: source_entity_path - }, (err, entity, type) -> - if err? - if err.toString().match(/^not found.*/) - err = new SourceFileNotFoundError() - return callback(err) - ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback + ProjectLocator.findElementByPath { + project_id: source_project_id, + path: source_entity_path + }, (err, entity, type) -> + if err? + if err.toString().match(/^not found.*/) + err = new SourceFileNotFoundError() + return callback(err) + ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) -> callback = _.once(callback) diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee index 567a1b4c39..59422c5e67 100644 --- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee @@ -27,6 +27,9 @@ module.exports = UrlAgent = { url: @._prependHttpIfNeeded(data.url) } + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback(null, true) + writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) -> callback = _.once(callback) url = data.url From b5e8ed81b9706e4f63438cdb1e895c0f68ee8f7c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:30:03 +0100 Subject: [PATCH 047/135] Better sanitization and validation for project-linked-file --- .../Features/LinkedFiles/ProjectFileAgent.coffee | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 55f20ed9a7..75aadf76fc 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -43,13 +43,18 @@ SourceFileNotFoundError.prototype.__proto__ = Error.prototype module.exports = ProjectFileAgent = sanitizeData: (data) -> - return data + return _.pick( + data, + 'source_project_id', + 'source_entity_path', + 'source_project_display_name' + ) _validate: (data) -> return ( - !!data.source_project_id && - !!data.source_entity_path && - !!data.source_project_display_name + data.source_project_id? && + data.source_entity_path? && + data.source_project_display_name? ) checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> From 8766b5d487dfc52c8f6c830c836d6ba2f81ae1bf Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:30:29 +0100 Subject: [PATCH 048/135] DRY up writing to dump-folder in FileWriter --- .../app/coffee/infrastructure/FileWriter.coffee | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee index 21353e7a36..27b1f16921 100644 --- a/services/web/app/coffee/infrastructure/FileWriter.coffee +++ b/services/web/app/coffee/infrastructure/FileWriter.coffee @@ -7,13 +7,18 @@ request = require 'request' module.exports = FileWriter = - writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) -> - callback = _.once(callback) - fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" + _ensureDumpFolderExists: (callback=(error)->) -> fs.mkdir Settings.path.dumpFolder, (error) -> if error? and error.code != 'EEXIST' # Ignore error about already existing return callback(error) + callback(null) + + writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) -> + callback = _.once(callback) + fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? fs.writeFile fsPath, lines.join('\n'), (error) -> return callback(error) if error? callback(null, fsPath) @@ -23,11 +28,9 @@ module.exports = FileWriter = fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" stream.pause() - fs.mkdir Settings.path.dumpFolder, (error) -> + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? stream.resume() - if error? and error.code != 'EEXIST' - # Ignore error about already existing - return callback(error) writeStream = fs.createWriteStream(fsPath) stream.pipe(writeStream) From 578d667efa7ff410481bbb9d3a9b1d6c855f1835 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:30:53 +0100 Subject: [PATCH 049/135] Disable the 'select a project/file' options in project-linked-file modal --- services/web/app/views/project/editor/file-tree.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 67a3a18f04..d16258d1c9 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -362,7 +362,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') ng-model="data.selectedProjectId" ng-disabled="!shouldEnableProjectSelect()" ) - option(value="") - No Project Selected + option(value="" disabled selected) - Please Select a Project option( ng-repeat="project in data.projects" value="{{ project._id }}" @@ -379,7 +379,7 @@ script(type='text/ng-template', id='projectLinkedFileModalTemplate') ng-model="data.selectedProjectEntity" ng-disabled="!shouldEnableProjectEntitySelect()" ) - option(value="") - No File Selected + option(value="" disabled selected) - Please Select a File option( ng-repeat="projectEntity in data.projectEntities" value="{{ projectEntity.path }}" From 656d40ac397ae75ec11f790afe2d093a7a5e0a5c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:31:50 +0100 Subject: [PATCH 050/135] Better view refresh after refreshing linked file --- .../ide/binary-files/BinaryFilesManager.coffee | 14 -------------- .../controllers/BinaryFileController.coffee | 16 ++++++++++++++-- .../coffee/ide/file-tree/FileTreeManager.coffee | 9 --------- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee index fc86208717..ebecf1132e 100644 --- a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee +++ b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee @@ -19,17 +19,3 @@ define [ , 0 , this ) - - openFileById: (id) -> - file = @ide.fileTreeManager.selectEntityById(id) - @$scope.ui.view = "file" - @$scope.openFile = null - @$scope.$apply() - window.setTimeout( - () => - @$scope.openFile = file - @$scope.$apply() - @$scope.$digest() - , 0 - , this - ) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 3ccffc9123..6b9c075a29 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -47,6 +47,18 @@ define [ else return url + _tryOpenFile = (new_file_id) -> + iterations = 0 + do tryOpen = () -> + if iterations > 10 + return + iterations += 1 + newFile = ide.fileTreeManager.findEntityById(new_file_id) + if newFile? + ide.binaryFilesManager.openFile(newFile) + else + setTimeout(tryOpen, 500) + $scope.refreshFile = (file) -> $scope.refreshing = true $scope.refreshError = null @@ -56,8 +68,8 @@ define [ { new_file_id } = data $timeout( () -> - ide.binaryFilesManager.openFileById(new_file_id) - , 1000 + _tryOpenFile(new_file_id) + , 0 ) $scope.refreshError = null .catch (response) -> diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index dfaef74edb..d7a428ec80 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -87,15 +87,6 @@ define [ entity.selected = false entity.selected = true - selectEntityById: (entity_id) -> - @selected_entity_id = entity_id # For reselecting after a reconnect - selected_entity = null - @ide.fileTreeManager.forEachEntity (entity) -> - if entity.id == entity_id - selected_entity = entity - entity.selected = true - return selected_entity - toggleMultiSelectEntity: (entity) -> entity.multiSelected = !entity.multiSelected @$scope.multiSelectedCount = @multiSelectedCount() From 16419847ae248a052959a40a26f0c0562d42af3e Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:32:20 +0100 Subject: [PATCH 051/135] Fix linked-file-types check when opening modal --- .../coffee/ide/file-tree/controllers/FileTreeController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index bf5a6af511..5b92ded9da 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -44,7 +44,7 @@ define [ ) $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () -> - unless 'url' in window.data.enabledLinkedFileTypes + unless 'project_file' in window.data.enabledLinkedFileTypes console.warn("Project linked files are not enabled") return $modal.open( From de1f33a7207c7add4f4fb242e53a8a33f585811f Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:32:44 +0100 Subject: [PATCH 052/135] Remove 'private' methods from the controller scope --- .../controllers/FileTreeController.coffee | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 5b92ded9da..0f1da48731 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -242,10 +242,10 @@ define [ if fileName $scope.data.name = fileName - $scope._setInFlight = (type) -> + _setInFlight = (type) -> $scope.state.inFlight[type] = true - $scope._reset = (opts) -> + _reset = (opts) -> isError = opts.err == true inFlight = $scope.state.inFlight inFlight.projects = inFlight.entities = inFlight.create = false @@ -273,7 +273,7 @@ define [ data.name $scope.getUserProjects = () -> - $scope._setInFlight('projects') + _setInFlight('projects') ide.$http.get("/user/projects", { _csrf: window.csrfToken }) @@ -281,25 +281,25 @@ define [ $scope.data.projectEntities = null $scope.data.projects = resp.data.projects.filter (p) -> p._id != ide.project_id - $scope._reset(err: false) + _reset(err: false) .catch (err) -> - $scope._reset(err: true) + _reset(err: true) $scope.getProjectEntities = (project_id) => - $scope._setInFlight('entities') + _setInFlight('entities') ide.$http.get("/project/#{project_id}/entities", { _csrf: window.csrfToken }) .then (resp) -> if $scope.data.selectedProjectId == resp.data.project_id $scope.data.projectEntities = resp.data.entities - $scope._reset(err: false) + _reset(err: false) .catch (err) -> - $scope._reset(err: true) + _reset(err: true) $scope.init = () -> $scope.getUserProjects() - $timeout($scope.init, 100) + $timeout($scope.init, 0) $scope.create = () -> projectId = $scope.data.selectedProjectId @@ -307,9 +307,9 @@ define [ path = $scope.data.selectedProjectEntity name = $scope.data.name if !name || !path || !projectId || !projectDisplayName - $scope._reset(err: true) + _reset(err: true) return - $scope._setInFlight('create') + _setInFlight('create') ide.fileTreeManager .createLinkedFile(name, parent_folder, 'project_file', { source_project_id: projectId, @@ -317,11 +317,11 @@ define [ source_project_display_name: projectDisplayName }) .then () -> - $scope._reset(err: false) + _reset(err: false) $modalInstance.close() .catch (response)-> { data } = response - $scope._reset(err: true) + _reset(err: true) $scope.cancel = () -> $modalInstance.dismiss('cancel') From 73a45b15ce24fc775f90ec49af12501c978bd5e7 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Thu, 24 May 2018 11:35:55 +0100 Subject: [PATCH 053/135] Make string replace op safer --- .../ide/binary-files/controllers/BinaryFileController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 6b9c075a29..fddf315f66 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -110,7 +110,7 @@ define [ if data.length >= (TWO_MEGABYTES - 200) $scope.textPreview.shouldShowDots = true # remove last partial line - data = data.replace(/\n.*$/, '') + data = data?.replace?(/\n.*$/, '') $scope.textPreview.data = data $timeout(setHeight, 0) .catch (error) -> From 3849bcfb400f266376b7250a6c44a739bcb34b20 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 10:36:20 +0100 Subject: [PATCH 054/135] Add a `waitFor` helper to the ide object --- services/web/public/coffee/ide.coffee | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 4f2d914135..3ddf9521b6 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -229,4 +229,18 @@ define [ ide.$scope.project.publicAccesLevel = data.newAccessLevel $scope.$digest() + ide.waitFor = (fn, callback, timeout) -> + sleepTime = 500 + iterationLimit = Math.floor(timeout / sleepTime) + iterations = 0 + do tryIteration = () -> + if iterations > iterationLimit + return + iterations += 1 + result = fn() + if result? + callback(result) + else + setTimeout(tryIteration, sleepTime) + angular.bootstrap(document.body, ["SharelatexApp"]) From 19d870094789027a9916f12f62403fbf73d1a6de Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 10:36:39 +0100 Subject: [PATCH 055/135] Use `waitFor` when refreshing the binary file view --- .../controllers/BinaryFileController.coffee | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index fddf315f66..ddff96e1d4 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -47,18 +47,6 @@ define [ else return url - _tryOpenFile = (new_file_id) -> - iterations = 0 - do tryOpen = () -> - if iterations > 10 - return - iterations += 1 - newFile = ide.fileTreeManager.findEntityById(new_file_id) - if newFile? - ide.binaryFilesManager.openFile(newFile) - else - setTimeout(tryOpen, 500) - $scope.refreshFile = (file) -> $scope.refreshing = true $scope.refreshError = null @@ -68,7 +56,13 @@ define [ { new_file_id } = data $timeout( () -> - _tryOpenFile(new_file_id) + ide.waitFor( + () -> + ide.fileTreeManager.findEntityById(new_file_id) + (newFile) -> + ide.binaryFilesManager.openFile(newFile) + 5000 + ) , 0 ) $scope.refreshError = null From e33b7b1a4925e0f2672d17d7b6d79c6826e00286 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 10:37:04 +0100 Subject: [PATCH 056/135] Use `waitFor` when restoring a file in v2 history --- .../HistoryV2DiffController.coffee | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index c2ced4cf59..a547f42f94 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -24,17 +24,14 @@ define [ $scope.restoreState.inflight = false openEntity = (data) -> - iterations = 0 {id, type} = data - do tryOpen = () -> - if iterations > 5 - return - iterations += 1 - entity = ide.fileTreeManager.findEntityById(id) - if entity? and type == 'doc' - ide.editorManager.openDoc(entity) - else if entity? and type == 'file' - ide.binaryFilesManager.openFile(entity) - else - setTimeout(tryOpen, 500) - \ No newline at end of file + ide.waitFor( + () -> + ide.fileTreeManager.findEntityById(id) + (entity) -> + if type == 'doc' + ide.editorManager.openDoc(entity) + else type == 'file' + ide.binaryFilesManager.openFile(entity) + 3000 + ) From f5f253ad019ca844debd67f179e266827b8010ba Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 10:56:08 +0100 Subject: [PATCH 057/135] Add an optional pollInterval parameter to waitFor --- services/web/public/coffee/ide.coffee | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 3ddf9521b6..312d528670 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -229,18 +229,17 @@ define [ ide.$scope.project.publicAccesLevel = data.newAccessLevel $scope.$digest() - ide.waitFor = (fn, callback, timeout) -> - sleepTime = 500 - iterationLimit = Math.floor(timeout / sleepTime) + ide.waitFor = (testFunction, callback, timeout, pollInterval=500) -> + iterationLimit = Math.floor(timeout / pollInterval) iterations = 0 do tryIteration = () -> if iterations > iterationLimit return iterations += 1 - result = fn() + result = testFunction() if result? callback(result) else - setTimeout(tryIteration, sleepTime) + setTimeout(tryIteration, pollInterval) angular.bootstrap(document.body, ["SharelatexApp"]) From 8be4279165e347953d31cc65cf6dcf8bf8f5f932 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 10:56:32 +0100 Subject: [PATCH 058/135] Fix a broken if-else-if statement --- .../ide/history/controllers/HistoryV2DiffController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index a547f42f94..826d3381f1 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -31,7 +31,7 @@ define [ (entity) -> if type == 'doc' ide.editorManager.openDoc(entity) - else type == 'file' + else if type == 'file' ide.binaryFilesManager.openFile(entity) 3000 ) From cfc17d56e8f4702a047a5009cde15313178a148d Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 11:42:13 +0100 Subject: [PATCH 059/135] Use a promise (with Angular's `$q`) in `waitFor` --- services/web/public/coffee/ide.coffee | 25 +++++++++++-------- .../controllers/BinaryFileController.coffee | 6 +++-- .../HistoryV2DiffController.coffee | 8 +++--- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 312d528670..424f71988a 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -55,7 +55,7 @@ define [ SafariScrollPatcher ) -> - App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) -> + App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) -> # Don't freak out if we're already in an apply callback $scope.$originalApply = $scope.$apply $scope.$apply = (fn = () ->) -> @@ -229,17 +229,20 @@ define [ ide.$scope.project.publicAccesLevel = data.newAccessLevel $scope.$digest() - ide.waitFor = (testFunction, callback, timeout, pollInterval=500) -> + ide.waitFor = (testFunction, timeout, pollInterval=500) -> iterationLimit = Math.floor(timeout / pollInterval) iterations = 0 - do tryIteration = () -> - if iterations > iterationLimit - return - iterations += 1 - result = testFunction() - if result? - callback(result) - else - setTimeout(tryIteration, pollInterval) + $q( + (resolve, reject) -> + do tryIteration = () -> + if iterations > iterationLimit + return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}")) + iterations += 1 + result = testFunction() + if result? + resolve(result) + else + setTimeout(tryIteration, pollInterval) + ) angular.bootstrap(document.body, ["SharelatexApp"]) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index ddff96e1d4..ed6fb8ec11 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -59,10 +59,12 @@ define [ ide.waitFor( () -> ide.fileTreeManager.findEntityById(new_file_id) - (newFile) -> - ide.binaryFilesManager.openFile(newFile) 5000 ) + .then (newFile) -> + ide.binaryFilesManager.openFile(newFile) + .catch (err) -> + console.warn(err) , 0 ) $scope.refreshError = null diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index 826d3381f1..5db663cb5e 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -28,10 +28,12 @@ define [ ide.waitFor( () -> ide.fileTreeManager.findEntityById(id) - (entity) -> + 3000 + ) + .then (entity) -> if type == 'doc' ide.editorManager.openDoc(entity) else if type == 'file' ide.binaryFilesManager.openFile(entity) - 3000 - ) + .catch (err) -> + console.warn(err) From 105d858155668cab54cfe5c025da26cdd8c30c5f Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 13:02:58 +0100 Subject: [PATCH 060/135] Move `waitFor` into an angular service --- services/web/public/coffee/ide.coffee | 17 +--------------- .../controllers/BinaryFileController.coffee | 4 ++-- .../HistoryV2DiffController.coffee | 4 ++-- .../public/coffee/services/wait-for.coffee | 20 +++++++++++++++++++ 4 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 services/web/public/coffee/services/wait-for.coffee diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 424f71988a..6c5b1d920e 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -35,6 +35,7 @@ define [ "directives/videoPlayState" "services/queued-http" "services/validateCaptcha" + "services/wait-for" "filters/formatDate" "main/event" "main/account-upgrade" @@ -229,20 +230,4 @@ define [ ide.$scope.project.publicAccesLevel = data.newAccessLevel $scope.$digest() - ide.waitFor = (testFunction, timeout, pollInterval=500) -> - iterationLimit = Math.floor(timeout / pollInterval) - iterations = 0 - $q( - (resolve, reject) -> - do tryIteration = () -> - if iterations > iterationLimit - return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}")) - iterations += 1 - result = testFunction() - if result? - resolve(result) - else - setTimeout(tryIteration, pollInterval) - ) - angular.bootstrap(document.body, ["SharelatexApp"]) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index ed6fb8ec11..bba455c447 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -2,7 +2,7 @@ define [ "base" "moment" ], (App, moment) -> - App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) -> + App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) -> TWO_MEGABYTES = 2 * 1024 * 1024 @@ -56,7 +56,7 @@ define [ { new_file_id } = data $timeout( () -> - ide.waitFor( + waitFor( () -> ide.fileTreeManager.findEntityById(new_file_id) 5000 diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index 5db663cb5e..279c230afb 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) -> + App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) -> $scope.restoreState = inflight: false error: false @@ -25,7 +25,7 @@ define [ openEntity = (data) -> {id, type} = data - ide.waitFor( + waitFor( () -> ide.fileTreeManager.findEntityById(id) 3000 diff --git a/services/web/public/coffee/services/wait-for.coffee b/services/web/public/coffee/services/wait-for.coffee new file mode 100644 index 0000000000..409142354c --- /dev/null +++ b/services/web/public/coffee/services/wait-for.coffee @@ -0,0 +1,20 @@ +define [ + "base" +], (App) -> + App.factory "waitFor", ($q) -> + waitFor = (testFunction, timeout, pollInterval=500) -> + iterationLimit = Math.floor(timeout / pollInterval) + iterations = 0 + $q( + (resolve, reject) -> + do tryIteration = () -> + if iterations > iterationLimit + return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}")) + iterations += 1 + result = testFunction() + if result? + resolve(result) + else + setTimeout(tryIteration, pollInterval) + ) + return waitFor From c4da8701c8b23d366a75d195c5f6815c4b6c7384 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 13:38:19 +0100 Subject: [PATCH 061/135] On v2, use smaller (default) border radius on select inputs --- services/web/public/stylesheets/components/forms.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/public/stylesheets/components/forms.less b/services/web/public/stylesheets/components/forms.less index 8eb92411cd..62def5cf9d 100755 --- a/services/web/public/stylesheets/components/forms.less +++ b/services/web/public/stylesheets/components/forms.less @@ -149,6 +149,10 @@ output { textarea& { height: auto; } + // Smaller border-radius for `select` inputs + select& { + border-radius: @border-radius-base; + } } From 2a11a70cd3cde2a5ba981575c2bad9cf8c399473 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 14:48:37 +0100 Subject: [PATCH 062/135] Use smaller border-radius on textarea inputs, on v2 --- services/web/public/stylesheets/components/forms.less | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/components/forms.less b/services/web/public/stylesheets/components/forms.less index 62def5cf9d..00653b105b 100755 --- a/services/web/public/stylesheets/components/forms.less +++ b/services/web/public/stylesheets/components/forms.less @@ -145,9 +145,10 @@ output { opacity: 1; // iOS fix for unreadable disabled content } - // Reset height for `textarea`s + // Reset height for `textarea`s, and smaller border-radius textarea& { height: auto; + border-radius: @border-radius-base; } // Smaller border-radius for `select` inputs select& { From 868083676f63b84f529f51457df86163435f5312 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Wed, 23 May 2018 16:02:02 -0500 Subject: [PATCH 063/135] Move contact form --- .../app/coffee/infrastructure/Modules.coffee | 3 +- services/web/app/views/contact-us-modal.pug | 67 ----------------- services/web/app/views/layout.pug | 2 +- .../web/public/coffee/main/contact-us.coffee | 72 ------------------- .../coffee/services/algolia-search.coffee | 4 +- 5 files changed, 5 insertions(+), 143 deletions(-) delete mode 100644 services/web/app/views/contact-us-modal.pug diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee index 769182ad94..e1b2e11520 100644 --- a/services/web/app/coffee/infrastructure/Modules.coffee +++ b/services/web/app/coffee/infrastructure/Modules.coffee @@ -30,7 +30,8 @@ module.exports = Modules = for module in @modules for view, partial of module.viewIncludes or {} @viewIncludes[view] ||= [] - @viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html") + filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug") + @viewIncludes[view].push pug.compileFile(filePath, doctype: "html") moduleIncludes: (view, locals) -> compiledPartials = Modules.viewIncludes[view] or [] diff --git a/services/web/app/views/contact-us-modal.pug b/services/web/app/views/contact-us-modal.pug deleted file mode 100644 index eacfd10533..0000000000 --- a/services/web/app/views/contact-us-modal.pug +++ /dev/null @@ -1,67 +0,0 @@ -script(type='text/ng-template', id='supportModalTemplate') - .modal-header - button.close( - type="button" - data-dismiss="modal" - ng-click="close()" - ) × - h3 #{translate("contact_us")} - .modal-body.contact-us-modal - form(name="contactForm") - span(ng-show="sent == false") - .alert.alert-danger(ng-show="error") Something went wrong sending your request :( - label - | #{translate("subject")} - .form-group - input.field.text.medium.span8.form-control( - name="subject", - required - ng-model="form.subject", - ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }" - maxlength='255', - tabindex='1', - onkeyup='') - .contact-suggestions(ng-show="suggestions.length") - p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "" + translate("knowledge_base") + "" })} - ul.contact-suggestion-list - li(ng-repeat="suggestion in suggestions") - a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank") - span(ng-bind-html="suggestion.name") - i.fa.fa-angle-right - label.desc(ng-show="'"+getUserEmail()+"'.length < 1") - | #{translate("email")} - .form-group(ng-show="'"+getUserEmail()+"'.length < 1") - input.field.text.medium.span8.form-control( - name="email", - required - ng-model="form.email", - ng-init="form.email = '"+getUserEmail()+"'", - type='email', spellcheck='false', - value='', - maxlength='255', - tabindex='2') - label#title12.desc - | #{translate("project_url")} (#{translate("optional")}) - .form-group - input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='') - label.desc - | #{translate("contact_message_label")} - .form-group - textarea.field.text.medium.span8.form-control( - name="body", - required - ng-model="form.message", - type='text', - value='', - tabindex='4', - onkeyup='' - ) - .form-group.text-center - input.btn-success.btn.btn-lg( - type='submit', - ng-disabled="contactForm.$invalid || sending", - ng-click="contactUs()" - value=translate("contact_us") - ) - span(ng-show="sent") - p #{translate("request_sent_thank_you")} diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 4dfdbc3ae7..687f5e1880 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product') src=buildJsPath('libs/require.js', {hashedPath:true}) ) - include contact-us-modal + != moduleIncludes("contactModal", locals) include v1-tooltip include sentry diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee index 2d1dee6d34..6d3e441c49 100644 --- a/services/web/public/coffee/main/contact-us.coffee +++ b/services/web/public/coffee/main/contact-us.coffee @@ -1,79 +1,7 @@ define [ "base" "libs/platform" - "services/algolia-search" ], (App, platform) -> - App.controller 'ContactModal', ($scope, $modal) -> - $scope.contactUsModal = () -> - modalInstance = $modal.open( - templateUrl: "supportModalTemplate" - controller: "SupportModalController" - ) - - App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) -> - $scope.form = {} - $scope.sent = false - $scope.sending = false - $scope.suggestions = []; - - _handleSearchResults = (success, results) -> - suggestions = for hit in results.hits - page_underscored = hit.pageName.replace(/\s/g,'_') - - suggestion = - url :"/learn/kb/#{page_underscored}" - name : hit._highlightResult.pageName.value - - event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length - - $scope.$applyAsync () -> - $scope.suggestions = suggestions - - $scope.contactUs = -> - if !$scope.form.email? or $scope.form.email == "" - console.log "email not set" - return - $scope.sending = true - ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) - message = $scope.form.message - if $scope.form.project_url? - message = "#{message}\n\n project_url = #{$scope.form.project_url}" - params = - email: $scope.form.email - message: message or "" - subject: $scope.form.subject + " - [#{ticketNumber}]" - labels: "support" - about: "
browser: #{platform?.name} #{platform?.version}
-
os: #{platform?.os?.family} #{platform?.os?.version}
" - - Groove.createTicket params, (response)-> - $scope.sending = false - if response.responseText == "" # Blocked request or similar - $scope.error = true - else - data = JSON.parse(response.responseText) - if data.errors? - $scope.error = true - else - $scope.sent = true - $scope.$apply() - - $scope.$watch "form.subject", (newVal, oldVal) -> - if newVal and newVal != oldVal and newVal.length > 3 - algoliaSearch.searchKB newVal, _handleSearchResults, { - hitsPerPage: 3 - typoTolerance: 'strict' - } - else - $scope.suggestions = []; - - $scope.clickSuggestionLink = (url) -> - event_tracking.sendMB "contact-form-suggestions-clicked", { url } - - $scope.close = () -> - $modalInstance.close() - - App.controller 'UniverstiesContactController', ($scope, $modal, $http) -> $scope.form = {} diff --git a/services/web/public/coffee/services/algolia-search.coffee b/services/web/public/coffee/services/algolia-search.coffee index d62bc6389d..9ae5eae077 100644 --- a/services/web/public/coffee/services/algolia-search.coffee +++ b/services/web/public/coffee/services/algolia-search.coffee @@ -8,7 +8,7 @@ define [ kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb) service = - searchWiki: wikiIdx.search.bind(wikiIdx) - searchKB: kbIdx.search.bind(kbIdx) + searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null + searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null return service \ No newline at end of file From c8a8fe6af7aaf191d58c852b0fd23416ed85ac6e Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 16:01:11 +0100 Subject: [PATCH 064/135] Use Agent.handleError in case checkAuth produces an error --- .../coffee/Features/LinkedFiles/LinkedFilesController.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index 67acdcfc86..807325371f 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -24,8 +24,8 @@ module.exports = LinkedFilesController = { linkedFileData = Agent.sanitizeData(data) linkedFileData.provider = provider Agent.checkAuth project_id, data, user_id, (err, allowed) -> - return next(err) if err? - return ses.sendStatus(403) if !allowed + return Agent.handleError(err, req, res, next) if err? + return res.sendStatus(403) if !allowed Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> if error? logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' From bc7d6a64ede03970666373993811dfaf494a8c9c Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 16:01:37 +0100 Subject: [PATCH 065/135] Add a trailing comma --- services/web/app/coffee/router.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 7b0d11863b..2ec3011ea2 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -121,7 +121,7 @@ module.exports = class Router webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), - AuthorizationMiddlewear.ensureUserCanReadProject + AuthorizationMiddlewear.ensureUserCanReadProject, ProjectController.projectEntitiesJson webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage From ce147b012f34afd41fd5584aeeb34ddc9a1913e6 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 25 May 2018 16:03:45 +0100 Subject: [PATCH 066/135] Cleaner unpacking of data from scope --- .../ide/file-tree/controllers/FileTreeController.coffee | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 0f1da48731..0b9ae20def 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -252,13 +252,11 @@ define [ $scope.state.error = isError $scope.shouldEnableProjectSelect = () -> - state = $scope.state - data = $scope.data + { state, data } = $scope return !state.inFlight.projects && data.projects $scope.shouldEnableProjectEntitySelect = () -> - state = $scope.state - data = $scope.data + { state, data } = $scope return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId $scope.shouldEnableCreateButton = () -> From 5a590aa021ad02617d9ddbef72ba086d6ace76c4 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 23 May 2018 12:08:56 +0200 Subject: [PATCH 067/135] remove getUserOrCreateHoldingAccount function The function is deprecated --- .../coffee/Features/User/UserCreator.coffee | 9 --------- .../SubscriptionGroupHandlerTests.coffee | 3 --- .../unit/coffee/User/UserCreatorTests.coffee | 18 ------------------ 3 files changed, 30 deletions(-) diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index d08b953559..0a0cc8641e 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -1,19 +1,10 @@ User = require("../../models/User").User -UserLocator = require("./UserLocator") logger = require("logger-sharelatex") metrics = require('metrics-sharelatex') module.exports = UserCreator = - getUserOrCreateHoldingAccount: (email, callback = (err, user)->)-> - self = @ - UserLocator.findByEmail email, (err, user)-> - if user? - callback(err, user) - else - self.createNewUser email:email, holdingAccount:true, callback - createNewUser: (opts, callback)-> logger.log opts:opts, "creating new user" user = new User() diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index 1daf43bbd7..88b4366a30 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -24,9 +24,6 @@ describe "SubscriptionGroupHandler", -> getSubscriptionByMemberIdAndId: sinon.stub() getSubscription: sinon.stub() - @UserCreator = - getUserOrCreateHoldingAccount: sinon.stub().callsArgWith(1, null, @user) - @SubscriptionUpdater = addUserToGroup: sinon.stub().callsArgWith(2) removeUserFromGroup: sinon.stub().callsArgWith(2) diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index 8470e5621f..01d13bba10 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -25,24 +25,6 @@ describe "UserCreator", -> @email = "bob.oswald@gmail.com" - - describe "getUserOrCreateHoldingAccount", -> - - it "should immediately return the user if found", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null, @user) - @UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=> - assert.deepEqual returnedUser, @user - done() - - it "should create new holding account if the user is not found", (done)-> - @UserLocator.findByEmail.callsArgWith(1) - @UserCreator.createNewUser = sinon.stub().callsArgWith(1, null, @user) - @UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=> - @UserCreator.createNewUser.calledWith(email:@email, holdingAccount:true).should.equal true - assert.deepEqual returnedUser, @user - done() - - describe "createNewUser", -> it "should take the opts and put them in the model", (done)-> From bbaca91e572d0feb231d749de649782776e7b60f Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 23 May 2018 16:12:23 +0200 Subject: [PATCH 068/135] add UserGetter#getUserByMainEmail Use only that method to find users by email. --- .../CollaboratorsInviteController.coffee | 2 +- .../CollaboratorsInviteHandler.coffee | 2 +- .../PasswordReset/PasswordResetHandler.coffee | 2 +- .../SubscriptionGroupHandler.coffee | 3 +- .../coffee/Features/User/UserGetter.coffee | 10 ++++ .../coffee/Features/User/UserLocator.coffee | 6 -- .../User/UserRegistrationHandler.coffee | 3 +- .../coffee/Features/User/UserUpdater.coffee | 4 +- .../CollaboratorsInviteControllerTests.coffee | 15 +++-- .../CollaboratorsInviteHandlerTests.coffee | 18 +++--- .../PasswordResetHandlerTests.coffee | 8 +-- .../SubscriptionGroupHandlerTests.coffee | 11 ++-- .../unit/coffee/User/UserCreatorTests.coffee | 6 +- .../unit/coffee/User/UserGetterTests.coffee | 58 +++++++++++++++++++ .../unit/coffee/User/UserLocatorTests.coffee | 36 +++++------- .../User/UserRegistrationHandlerTests.coffee | 10 ++-- .../unit/coffee/User/UserUpdaterTests.coffee | 10 ++-- 17 files changed, 134 insertions(+), 70 deletions(-) create mode 100644 services/web/test/unit/coffee/User/UserGetterTests.coffee diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee index 898abe52bb..f74a144bac 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController = _checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) -> if Settings.restrictInvitesToExistingAccounts == true logger.log {email}, "checking if user exists with this email" - UserGetter.getUser {email: email}, {_id: 1}, (err, user) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) -> return callback(err) if err? userExists = user? and user?._id? callback(null, userExists) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee index ecca8ab86f..b511f56e53 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler = _trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) -> email = invite.email - UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) -> if err? logger.err {projectId, email}, "error checking if user exists" return callback(err) diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee index 4e67e9f1f4..3947b63004 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee @@ -9,7 +9,7 @@ logger = require("logger-sharelatex") module.exports = generateAndEmailResetToken:(email, callback = (error, exists) ->)-> - UserGetter.getUser email:email, (err, user)-> + UserGetter.getUserByMainEmail email, (err, user)-> if err then return callback(err) if !user? or user.holdingAccount logger.err email:email, "user could not be found for password reset" diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index 5b7c2e0740..74e1fb4098 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -3,6 +3,7 @@ _ = require("underscore") SubscriptionUpdater = require("./SubscriptionUpdater") SubscriptionLocator = require("./SubscriptionLocator") UserLocator = require("../User/UserLocator") +UserGetter = require("../User/UserGetter") LimitationsManager = require("./LimitationsManager") logger = require("logger-sharelatex") OneTimeTokenHandler = require("../Security/OneTimeTokenHandler") @@ -21,7 +22,7 @@ module.exports = SubscriptionGroupHandler = if limitReached logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) - UserLocator.findByEmail newEmail, (err, user)-> + UserGetter.getUserByMainEmail newEmail, (err, user)-> return callback(err) if err? if user? SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 306fbc7a10..201981625e 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId module.exports = UserGetter = getUser: (query, projection, callback = (error, user) ->) -> + if query?.email? + return callback(new Error("Don't use getUser to find user by email"), null) if arguments.length == 2 callback = projection projection = {} @@ -19,6 +21,13 @@ module.exports = UserGetter = db.users.findOne query, projection, callback + getUserByMainEmail: (email, projection, callback = (error, user) ->) -> + email = email.trim() + if arguments.length == 2 + callback = projection + projection = {} + db.users.findOne email: email, projection, callback + getUsers: (user_ids, projection, callback = (error, users) ->) -> try user_ids = user_ids.map (u) -> ObjectId(u.toString()) @@ -39,6 +48,7 @@ module.exports = UserGetter = [ 'getUser', + 'getUserByMainEmail', 'getUsers', 'getUserOrUserStubById' ].map (method) -> diff --git a/services/web/app/coffee/Features/User/UserLocator.coffee b/services/web/app/coffee/Features/User/UserLocator.coffee index 9be32c76b0..732865bed1 100644 --- a/services/web/app/coffee/Features/User/UserLocator.coffee +++ b/services/web/app/coffee/Features/User/UserLocator.coffee @@ -6,16 +6,10 @@ logger = require('logger-sharelatex') module.exports = UserLocator = - findByEmail: (email, callback)-> - email = email.trim() - db.users.findOne email:email, (err, user)-> - callback(err, user) - findById: (_id, callback)-> db.users.findOne _id:ObjectId(_id+""), callback [ 'findById', - 'findByEmail' ].map (method) -> metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index f5db2e54a1..fab438ffa6 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -1,6 +1,7 @@ sanitize = require('sanitizer') User = require("../../models/User").User UserCreator = require("./UserCreator") +UserGetter = require("./UserGetter") AuthenticationManager = require("../Authentication/AuthenticationManager") NewsLetterManager = require("../Newsletter/NewsletterManager") async = require("async") @@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler = if !requestIsValid return callback(new Error("request is not valid")) userDetails.email = userDetails.email?.trim()?.toLowerCase() - User.findOne email:userDetails.email, (err, user)-> + UserGetter.getUserByMainEmail userDetails.email, (err, user) => if err? return callback err if user?.holdingAccount == false diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index 530d81063d..174d37bc38 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -3,7 +3,7 @@ mongojs = require("../../infrastructure/mongojs") metrics = require("metrics-sharelatex") db = mongojs.db ObjectId = mongojs.ObjectId -UserLocator = require("./UserLocator") +UserGetter = require("./UserGetter") module.exports = UserUpdater = updateUser: (query, update, callback = (error) ->) -> @@ -18,7 +18,7 @@ module.exports = UserUpdater = changeEmailAddress: (user_id, newEmail, callback)-> self = @ logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user" - UserLocator.findByEmail newEmail, (error, user) -> + UserGetter.getUserByMainEmail newEmail, (error, user) -> if user? return callback({message:"alread_exists"}) self.updateUser user_id.toString(), { diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee index cdfff78249..4b57ff3697 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -24,11 +24,14 @@ describe "CollaboratorsInviteController", -> addCount: sinon.stub @LimitationsManager = {} + @UserGetter = + getUserByMainEmail: sinon.stub() + getUser: sinon.stub() @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: "../Project/ProjectGetter": @ProjectGetter = {} '../Subscription/LimitationsManager' : @LimitationsManager - '../User/UserGetter': @UserGetter = {getUser: sinon.stub()} + '../User/UserGetter': @UserGetter "./CollaboratorsHandler": @CollaboratorsHandler = {} "./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} 'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()} @@ -713,7 +716,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = {_id: ObjectId().toString()} - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `true`', (done) -> @call (err, shouldAllow) => @@ -725,7 +728,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = null - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `false`', (done) -> @call (err, shouldAllow) => @@ -735,15 +738,15 @@ describe "CollaboratorsInviteController", -> it 'should have called getUser', (done) -> @call (err, shouldAllow) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @email}, {_id: 1}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true done() describe 'when getUser produces an error', -> beforeEach -> @user = null - @UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should callback with an error', (done) -> @call (err, shouldAllow) => diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee index 177c42d4ba..58b373f61f 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", -> _id: ObjectId() first_name: "jim" @existingUser = {_id: ObjectId()} - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser) @fakeProject = _id: @project_id name: "some project" @@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should call getProject', (done) -> @@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the user does not exist', -> beforeEach -> - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, null) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null) it 'should not produce an error', (done) -> @call (err) => @@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> @@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the getUser produces an error', -> beforeEach -> - @UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err) => @@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> diff --git a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee index b29839246a..261f5582dd 100644 --- a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee +++ b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee @@ -16,7 +16,7 @@ describe "PasswordResetHandler", -> getNewToken:sinon.stub() getValueFromTokenAndExpire:sinon.stub() @UserGetter = - getUser:sinon.stub() + getUserByMainEmail:sinon.stub() @EmailHandler = sendEmail:sinon.stub() @AuthenticationManager = @@ -40,7 +40,7 @@ describe "PasswordResetHandler", -> describe "generateAndEmailResetToken", -> it "should check the user exists", (done)-> - @UserGetter.getUser.callsArgWith(1) + @UserGetter.getUserByMainEmail.callsArgWith(1) @OneTimeTokenHandler.getNewToken.callsArgWith(1) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> exists.should.equal false @@ -49,7 +49,7 @@ describe "PasswordResetHandler", -> it "should send the email with the token", (done)-> - @UserGetter.getUser.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) @OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token) @EmailHandler.sendEmail.callsArgWith(2) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> @@ -62,7 +62,7 @@ describe "PasswordResetHandler", -> it "should return exists = false for a holdingAccount", (done) -> @user.holdingAccount = true - @UserGetter.getUser.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) @OneTimeTokenHandler.getNewToken.callsArgWith(1) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> exists.should.equal false diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index 88b4366a30..d5db1314d9 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -32,7 +32,9 @@ describe "SubscriptionGroupHandler", -> @UserLocator = findById: sinon.stub() - findByEmail: sinon.stub() + + @UserGetter = + getUserByMainEmail: sinon.stub() @LimitationsManager = hasGroupMembersLimitReached: sinon.stub() @@ -57,6 +59,7 @@ describe "SubscriptionGroupHandler", -> "./SubscriptionUpdater": @SubscriptionUpdater "./SubscriptionLocator": @SubscriptionLocator "../User/UserLocator": @UserLocator + "../User/UserGetter": @UserGetter "./LimitationsManager": @LimitationsManager "../Security/OneTimeTokenHandler":@OneTimeTokenHandler "../Email/EmailHandler":@EmailHandler @@ -71,11 +74,11 @@ describe "SubscriptionGroupHandler", -> describe "addUserToGroup", -> beforeEach -> @LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription) - @UserLocator.findByEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should find the user", (done)-> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> - @UserLocator.findByEmail.calledWith(@newEmail).should.equal true + @UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true done() it "should add the user to the group", (done)-> @@ -102,7 +105,7 @@ describe "SubscriptionGroupHandler", -> done() it "should add an email invite if no user is found", (done) -> - @UserLocator.findByEmail.callsArgWith(1, null, null) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, null) @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true done() diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index 01d13bba10..cc2b1ec150 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -15,11 +15,11 @@ describe "UserCreator", -> constructor: -> return self.user - @UserLocator = - findByEmail: sinon.stub() + @UserGetter = + getUserByMainEmail: sinon.stub() @UserCreator = SandboxedModule.require modulePath, requires: "../../models/User": User:@UserModel - "./UserLocator":@UserLocator + "./UserGetter":@UserGetter "logger-sharelatex":{log:->} 'metrics-sharelatex': {timeAsyncMethod: ()->} diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee new file mode 100644 index 0000000000..2c32bd8250 --- /dev/null +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -0,0 +1,58 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = path.join __dirname, "../../../../app/js/Features/User/UserGetter" +expect = require("chai").expect + +describe "UserGetter", -> + + beforeEach -> + @fakeUser = {_id:"12390i"} + @findOne = sinon.stub().callsArgWith(2, null, @fakeUser) + @Mongo = + db: users: findOne: @findOne + ObjectId: (id) -> return id + + @UserGetter = SandboxedModule.require modulePath, requires: + "logger-sharelatex": log:-> + "../../infrastructure/mongojs": @Mongo + "metrics-sharelatex": timeAsyncMethod: sinon.stub() + + describe "getUser", -> + it "should get user", (done)-> + query = _id: 'foo' + projection = email: 1 + @UserGetter.getUser query, projection, (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(query, projection).should.equal true + user.should.deep.equal @fakeUser + done() + + it "should not allow email in query", (done)-> + @UserGetter.getUser email: 'foo@bar.com', {}, (error, user) => + error.should.exist + done() + + describe "getUserbyMainEmail", -> + it "query user by main email", (done)-> + email = 'hello@world.com' + projection = emails: 1 + @UserGetter.getUserByMainEmail email, projection, (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(email: email, projection).should.equal true + done() + + it "return user if found", (done)-> + email = 'hello@world.com' + @UserGetter.getUserByMainEmail email, (error, user) => + user.should.deep.equal @fakeUser + done() + + it "trim email", (done)-> + email = 'hello@world.com' + @UserGetter.getUserByMainEmail " #{email} ", (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(email: email).should.equal true + done() diff --git a/services/web/test/unit/coffee/User/UserLocatorTests.coffee b/services/web/test/unit/coffee/User/UserLocatorTests.coffee index dc3fc84dfa..0093dc7d61 100644 --- a/services/web/test/unit/coffee/User/UserLocatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserLocatorTests.coffee @@ -7,33 +7,25 @@ SandboxedModule = require('sandboxed-module') describe "UserLocator", -> beforeEach -> - @user = {_id:"12390i"} + @fakeUser = {_id:"12390i"} + @findOne = sinon.stub().callsArgWith(1, null, @fakeUser) + @Mongo = + db: users: findOne: @findOne + ObjectId: (id) -> return id + @UserLocator = SandboxedModule.require modulePath, requires: - "../../infrastructure/mongojs": db: @db = { users: {} } + "../../infrastructure/mongojs": @Mongo "metrics-sharelatex": timeAsyncMethod: sinon.stub() 'logger-sharelatex' : { log: sinon.stub() } - @db.users = - findOne : sinon.stub().callsArgWith(1, null, @user) - @email = "bob.oswald@gmail.com" - - - describe "findByEmail", -> - - it "should try and find a user with that email address", (done)-> - @UserLocator.findByEmail @email, (err, user)=> - @db.users.findOne.calledWith(email:@email).should.equal true - done() - - it "should trim white space", (done)-> - @UserLocator.findByEmail "#{@email} ", (err, user)=> - @db.users.findOne.calledWith(email:@email).should.equal true + describe "findById", -> + it "should try and find a user with that id", (done)-> + _id = '123e' + @UserLocator.findById _id, (err, user)=> + @findOne.calledWith(_id: _id).should.equal true done() it "should return the user if found", (done)-> - @UserLocator.findByEmail @email, (err, user)=> - user.should.deep.equal @user + @UserLocator.findById '123e', (err, user)=> + user.should.deep.equal @fakeUser done() - - - diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index d0b96da2de..9411059022 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -12,8 +12,9 @@ describe "UserRegistrationHandler", -> @user = _id: @user_id = "31j2lk21kjl" @User = - findOne:sinon.stub() update: sinon.stub().callsArgWith(2) + @UserGetter = + getUserByMainEmail: sinon.stub() @UserCreator = createNewUser:sinon.stub().callsArgWith(1, null, @user) @AuthenticationManager = @@ -26,6 +27,7 @@ describe "UserRegistrationHandler", -> getNewToken: sinon.stub() @handler = SandboxedModule.require modulePath, requires: "../../models/User": {User:@User} + "./UserGetter": @UserGetter "./UserCreator": @UserCreator "../Authentication/AuthenticationManager":@AuthenticationManager "../Newsletter/NewsletterManager":@NewsLetterManager @@ -70,7 +72,7 @@ describe "UserRegistrationHandler", -> beforeEach -> @user.holdingAccount = true @handler._registrationRequestIsValid = sinon.stub().returns true - @User.findOne.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should not create a new user if there is a holding account there", (done)-> @handler.registerNewUser @passingRequest, (err)=> @@ -94,7 +96,7 @@ describe "UserRegistrationHandler", -> done() it "should return email registered in the error if there is a non holdingAccount there", (done)-> - @User.findOne.callsArgWith(1, null, @user = {holdingAccount:false}) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false}) @handler.registerNewUser @passingRequest, (err, user)=> err.should.deep.equal new Error("EmailAlreadyRegistered") user.should.deep.equal @user @@ -103,7 +105,7 @@ describe "UserRegistrationHandler", -> describe "validRequest", -> beforeEach -> @handler._registrationRequestIsValid = sinon.stub().returns true - @User.findOne.callsArgWith 1 + @UserGetter.getUserByMainEmail.callsArgWith 1 it "should create a new user", (done)-> @handler.registerNewUser @passingRequest, (err)=> diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index a6239e2e65..202a4f3f1a 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -14,12 +14,12 @@ describe "UserUpdater", -> @mongojs = db:{} ObjectId:(id)-> return id - @UserLocator = - findByEmail:sinon.stub() + @UserGetter = + getUserByMainEmail: sinon.stub() @UserUpdater = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings "logger-sharelatex": log:-> - "./UserLocator":@UserLocator + "./UserGetter": @UserGetter "../../infrastructure/mongojs":@mongojs "metrics-sharelatex": timeAsyncMethod: sinon.stub() @@ -34,7 +34,7 @@ describe "UserUpdater", -> @UserUpdater.updateUser = sinon.stub().callsArgWith(2) it "should check if the new email already has an account", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null, @stubbedUser) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @stubbedUser) @UserUpdater.changeEmailAddress @user_id, @stubbedUser.email, (err)=> @UserUpdater.updateUser.called.should.equal false should.exist(err) @@ -42,7 +42,7 @@ describe "UserUpdater", -> it "should set the users password", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null) + @UserGetter.getUserByMainEmail.callsArgWith(1, null) @UserUpdater.changeEmailAddress @user_id, @newEmail, (err)=> @UserUpdater.updateUser.calledWith(@user_id, $set: { "email": @newEmail}).should.equal true done() From 5fbe5c5537cbebacbaf9129bb779d5ee916987d5 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Thu, 24 May 2018 15:55:12 +0200 Subject: [PATCH 069/135] remove UserLocator Use UserGetter instead --- .../BetaProgram/BetaProgramController.coffee | 4 +-- .../SubscriptionGroupHandler.coffee | 3 +- .../Features/User/UserController.coffee | 4 +-- .../coffee/Features/User/UserLocator.coffee | 15 --------- .../Features/User/UserPagesController.coffee | 3 +- .../BetaProgramControllerTests.coffee | 10 +++--- .../SubscriptionGroupHandlerTests.coffee | 19 +++++------- .../coffee/User/UserControllerTests.coffee | 6 ++-- .../unit/coffee/User/UserLocatorTests.coffee | 31 ------------------- .../User/UserPagesControllerTests.coffee | 9 +++--- 10 files changed, 26 insertions(+), 78 deletions(-) delete mode 100644 services/web/app/coffee/Features/User/UserLocator.coffee delete mode 100644 services/web/test/unit/coffee/User/UserLocatorTests.coffee diff --git a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee index 4e96ce113c..1e0577cfc1 100644 --- a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee +++ b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee @@ -1,5 +1,5 @@ BetaProgramHandler = require './BetaProgramHandler' -UserLocator = require "../User/UserLocator" +UserGetter = require "../User/UserGetter" Settings = require "settings-sharelatex" logger = require 'logger-sharelatex' AuthenticationController = require '../Authentication/AuthenticationController' @@ -30,7 +30,7 @@ module.exports = BetaProgramController = optInPage: (req, res, next)-> user_id = AuthenticationController.getLoggedInUserId(req) logger.log {user_id}, "showing beta participation page for user" - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> if err logger.err {err, user_id}, "error fetching user" return next(err) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index 74e1fb4098..d6ce0dde59 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -2,7 +2,6 @@ async = require("async") _ = require("underscore") SubscriptionUpdater = require("./SubscriptionUpdater") SubscriptionLocator = require("./SubscriptionLocator") -UserLocator = require("../User/UserLocator") UserGetter = require("../User/UserGetter") LimitationsManager = require("./LimitationsManager") logger = require("logger-sharelatex") @@ -51,7 +50,7 @@ module.exports = SubscriptionGroupHandler = users.push buildEmailInviteViewModel(email) jobs = _.map subscription.member_ids, (user_id)-> return (cb)-> - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> if err? or !user? users.push _id:user_id return cb() diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee index ccbd0a86f1..cf7e6be33f 100644 --- a/services/web/app/coffee/Features/User/UserController.coffee +++ b/services/web/app/coffee/Features/User/UserController.coffee @@ -1,6 +1,6 @@ UserHandler = require("./UserHandler") UserDeleter = require("./UserDeleter") -UserLocator = require("./UserLocator") +UserGetter = require("./UserGetter") User = require("../../models/User").User newsLetterManager = require('../Newsletter/NewsletterManager') UserRegistrationHandler = require("./UserRegistrationHandler") @@ -45,7 +45,7 @@ module.exports = UserController = unsubscribe: (req, res)-> user_id = AuthenticationController.getLoggedInUserId(req) - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> newsLetterManager.unsubscribe user, -> res.send() diff --git a/services/web/app/coffee/Features/User/UserLocator.coffee b/services/web/app/coffee/Features/User/UserLocator.coffee deleted file mode 100644 index 732865bed1..0000000000 --- a/services/web/app/coffee/Features/User/UserLocator.coffee +++ /dev/null @@ -1,15 +0,0 @@ -mongojs = require("../../infrastructure/mongojs") -metrics = require("metrics-sharelatex") -db = mongojs.db -ObjectId = mongojs.ObjectId -logger = require('logger-sharelatex') - -module.exports = UserLocator = - - findById: (_id, callback)-> - db.users.findOne _id:ObjectId(_id+""), callback - -[ - 'findById', -].map (method) -> - metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index 25825c35e6..5e6ea7d62b 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -1,4 +1,3 @@ -UserLocator = require("./UserLocator") UserGetter = require("./UserGetter") UserSessionsManager = require("./UserSessionsManager") ErrorController = require("../Errors/ErrorController") @@ -61,7 +60,7 @@ module.exports = user_id = AuthenticationController.getLoggedInUserId(req) logger.log user: user_id, "loading settings page" shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin) - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> return next(err) if err? res.render 'user/settings', title:'account_settings' diff --git a/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee b/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee index ab1f1b0567..713179b056 100644 --- a/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee +++ b/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee @@ -23,8 +23,8 @@ describe "BetaProgramController", -> optIn: sinon.stub() optOut: sinon.stub() }, - "../User/UserLocator": @UserLocator = { - findById: sinon.stub() + "../User/UserGetter": @UserGetter = { + getUser: sinon.stub() }, "settings-sharelatex": @settings = { languages: {} @@ -119,7 +119,7 @@ describe "BetaProgramController", -> describe "optInPage", -> beforeEach -> - @UserLocator.findById.callsArgWith(1, null, @user) + @UserGetter.getUser.callsArgWith(1, null, @user) it "should render the opt-in page", () -> @BetaProgramController.optInPage @req, @res, @next @@ -128,10 +128,10 @@ describe "BetaProgramController", -> args[0].should.equal 'beta_program/opt_in' - describe "when UserLocator.findById produces an error", -> + describe "when UserGetter.getUser produces an error", -> beforeEach -> - @UserLocator.findById.callsArgWith(1, new Error('woops')) + @UserGetter.getUser.callsArgWith(1, new Error('woops')) it "should not render the opt-in page", () -> @BetaProgramController.optInPage @req, @res, @next diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index d5db1314d9..bca9ac7600 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -30,10 +30,8 @@ describe "SubscriptionGroupHandler", -> addEmailInviteToGroup: sinon.stub().callsArgWith(2) removeEmailInviteFromGroup: sinon.stub().callsArgWith(2) - @UserLocator = - findById: sinon.stub() - @UserGetter = + getUser: sinon.stub() getUserByMainEmail: sinon.stub() @LimitationsManager = @@ -58,7 +56,6 @@ describe "SubscriptionGroupHandler", -> "../User/UserCreator": @UserCreator "./SubscriptionUpdater": @SubscriptionUpdater "./SubscriptionLocator": @SubscriptionLocator - "../User/UserLocator": @UserLocator "../User/UserGetter": @UserGetter "./LimitationsManager": @LimitationsManager "../Security/OneTimeTokenHandler":@OneTimeTokenHandler @@ -122,26 +119,26 @@ describe "SubscriptionGroupHandler", -> beforeEach -> @subscription = {} @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) it "should locate the subscription", (done)-> - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> @SubscriptionLocator.getUsersSubscription.calledWith(@adminUser_id).should.equal true done() it "should get the users by id", (done)-> - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) @subscription.member_ids = ["1234", "342432", "312312"] @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - @UserLocator.findById.calledWith(@subscription.member_ids[0]).should.equal true - @UserLocator.findById.calledWith(@subscription.member_ids[1]).should.equal true - @UserLocator.findById.calledWith(@subscription.member_ids[2]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true users.length.should.equal @subscription.member_ids.length done() it "should just return the id if the user can not be found as they may have deleted their account", (done)-> - @UserLocator.findById.callsArgWith(1) + @UserGetter.getUser.callsArgWith(1) @subscription.member_ids = ["1234", "342432", "312312"] @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> assert.deepEqual users[0], {_id:@subscription.member_ids[0]} diff --git a/services/web/test/unit/coffee/User/UserControllerTests.coffee b/services/web/test/unit/coffee/User/UserControllerTests.coffee index c358f35b22..e815d8d701 100644 --- a/services/web/test/unit/coffee/User/UserControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserControllerTests.coffee @@ -30,8 +30,8 @@ describe "UserController", -> @UserDeleter = deleteUser: sinon.stub().callsArgWith(1) - @UserLocator = - findById: sinon.stub().callsArgWith(1, null, @user) + @UserGetter = + getUser: sinon.stub().callsArgWith(1, null, @user) @User = findById: sinon.stub().callsArgWith(1, null, @user) @NewsLetterManager = @@ -63,7 +63,7 @@ describe "UserController", -> @SudoModeHandler = clearSudoMode: sinon.stub() @UserController = SandboxedModule.require modulePath, requires: - "./UserLocator": @UserLocator + "./UserGetter": @UserGetter "./UserDeleter": @UserDeleter "./UserUpdater":@UserUpdater "../../models/User": User:@User diff --git a/services/web/test/unit/coffee/User/UserLocatorTests.coffee b/services/web/test/unit/coffee/User/UserLocatorTests.coffee deleted file mode 100644 index 0093dc7d61..0000000000 --- a/services/web/test/unit/coffee/User/UserLocatorTests.coffee +++ /dev/null @@ -1,31 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/Features/User/UserLocator.js" -SandboxedModule = require('sandboxed-module') - -describe "UserLocator", -> - - beforeEach -> - @fakeUser = {_id:"12390i"} - @findOne = sinon.stub().callsArgWith(1, null, @fakeUser) - @Mongo = - db: users: findOne: @findOne - ObjectId: (id) -> return id - - @UserLocator = SandboxedModule.require modulePath, requires: - "../../infrastructure/mongojs": @Mongo - "metrics-sharelatex": timeAsyncMethod: sinon.stub() - 'logger-sharelatex' : { log: sinon.stub() } - - describe "findById", -> - it "should try and find a user with that id", (done)-> - _id = '123e' - @UserLocator.findById _id, (err, user)=> - @findOne.calledWith(_id: _id).should.equal true - done() - - it "should return the user if found", (done)-> - @UserLocator.findById '123e', (err, user)=> - user.should.deep.equal @fakeUser - done() diff --git a/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee b/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee index 529f5b1be6..a0f155846f 100644 --- a/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee @@ -16,10 +16,7 @@ describe "UserPagesController", -> features:{} email: "joe@example.com" - @UserLocator = - findById: sinon.stub().callsArgWith(1, null, @user) - @UserGetter = - getUser: sinon.stub().callsArgWith(2, null, @user) + @UserGetter = getUser: sinon.stub() @UserSessionsManager = getAllUserSessions: sinon.stub() @dropboxStatus = {} @@ -37,7 +34,6 @@ describe "UserPagesController", -> "logger-sharelatex": log:-> err:-> - "./UserLocator": @UserLocator "./UserGetter": @UserGetter "./UserSessionsManager": @UserSessionsManager "../Errors/ErrorController": @ErrorController @@ -136,6 +132,8 @@ describe "UserPagesController", -> @UserPagesController.sessionsPage @req, @res, @next describe "settingsPage", -> + beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user) it "should render user/settings", (done)-> @res.render = (page)-> @@ -185,6 +183,7 @@ describe "UserPagesController", -> describe "activateAccountPage", -> beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) @req.query.user_id = @user_id @req.query.token = @token = "mock-token-123" From 92fb83e665a1a6c5fd32d27c08ac9ad40bb9c6cf Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 29 May 2018 10:05:50 +0100 Subject: [PATCH 070/135] Use the correct linkedFileData var --- .../coffee/Features/LinkedFiles/LinkedFilesController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index 807325371f..e0a5998327 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -23,7 +23,7 @@ module.exports = LinkedFilesController = { linkedFileData = Agent.sanitizeData(data) linkedFileData.provider = provider - Agent.checkAuth project_id, data, user_id, (err, allowed) -> + Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) -> return Agent.handleError(err, req, res, next) if err? return res.sendStatus(403) if !allowed Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> From dccac6302eec8f3e72253f7aaa52c9ee023bf0bd Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 29 May 2018 10:07:31 +0100 Subject: [PATCH 071/135] Use a `decorateLinkedFileData` function on server to add project name --- .../LinkedFiles/LinkedFilesController.coffee | 19 ++++++++------ .../LinkedFiles/ProjectFileAgent.coffee | 26 ++++++++++++++++--- .../Features/LinkedFiles/UrlAgent.coffee | 3 +++ .../controllers/FileTreeController.coffee | 6 ++--- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index e0a5998327..1a5e13e86e 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -26,11 +26,14 @@ module.exports = LinkedFilesController = { Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) -> return Agent.handleError(err, req, res, next) if err? return res.sendStatus(403) if !allowed - Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> - if error? - logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' - return Agent.handleError(error, req, res, next) - EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> - return next(error) if error? - res.json(new_file_id: file._id) # created -} + Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) -> + return Agent.handleError(err) if err? + linkedFileData = newLinkedFileData + Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> + if error? + logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' + return Agent.handleError(error, req, res, next) + EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> + return next(error) if error? + res.json(new_file_id: file._id) # created + } diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee index 75aadf76fc..5ea4554426 100644 --- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -1,6 +1,7 @@ FileWriter = require('../../infrastructure/FileWriter') AuthorizationManager = require('../Authorization/AuthorizationManager') ProjectLocator = require('../Project/ProjectLocator') +ProjectGetter = require('../Project/ProjectGetter') DocstoreManager = require('../Docstore/DocstoreManager') FileStoreHandler = require('../FileStore/FileStoreHandler') FileWriter = require('../../infrastructure/FileWriter') @@ -32,6 +33,14 @@ BadDataError = (message) -> BadDataError.prototype.__proto__ = Error.prototype +ProjectNotFoundError = (message) -> + error = new Error(message) + error.name = 'ProjectNotFound' + error.__proto__ = ProjectNotFoundError.prototype + return error +ProjectNotFoundError.prototype.__proto__ = Error.prototype + + SourceFileNotFoundError = (message) -> error = new Error(message) error.name = 'BadData' @@ -46,17 +55,24 @@ module.exports = ProjectFileAgent = return _.pick( data, 'source_project_id', - 'source_entity_path', - 'source_project_display_name' + 'source_entity_path' ) _validate: (data) -> return ( data.source_project_id? && - data.source_entity_path? && - data.source_project_display_name? + data.source_entity_path? ) + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + callback = _.once(callback) + { source_project_id } = data + return callback(new BadDataError()) if !source_project_id? + ProjectGetter.getProject source_project_id, (err, project) -> + return callback(err) if err? + return callback(new ProjectNotFoundError()) if !project? + callback(err, _.extend(data, {source_project_display_name: project.name})) + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> callback = _.once(callback) if !ProjectFileAgent._validate(data) @@ -104,6 +120,8 @@ module.exports = ProjectFileAgent = res.status(400).send("The file is the wrong type") else if error instanceof SourceFileNotFoundError res.status(404).send("Source file not found") + else if error instanceof ProjectNotFoundError + res.status(404).send("Project not found") else next(error) next() diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee index 59422c5e67..7a15fe52d3 100644 --- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee @@ -27,6 +27,9 @@ module.exports = UrlAgent = { url: @._prependHttpIfNeeded(data.url) } + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + return callback(null, data) + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> callback(null, true) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 0b9ae20def..010e00476f 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -301,18 +301,16 @@ define [ $scope.create = () -> projectId = $scope.data.selectedProjectId - projectDisplayName = _.find($scope.data.projects, (p) -> p._id == projectId).name path = $scope.data.selectedProjectEntity name = $scope.data.name - if !name || !path || !projectId || !projectDisplayName + if !name || !path || !projectId _reset(err: true) return _setInFlight('create') ide.fileTreeManager .createLinkedFile(name, parent_folder, 'project_file', { source_project_id: projectId, - source_entity_path: path, - source_project_display_name: projectDisplayName + source_entity_path: path }) .then () -> _reset(err: false) From 14898acd7f8ee5f8dee4a7bc7efb3c6eb7663063 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 29 May 2018 10:40:38 +0100 Subject: [PATCH 072/135] Update linked-file acceptance tests --- .../web/test/acceptance/coffee/LinkedFilesTests.coffee | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee index 34079de8d1..f3e8694e2a 100644 --- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee +++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee @@ -105,7 +105,6 @@ describe "LinkedFiles", -> data: source_project_id: @project_two_id, source_entity_path: "/#{@source_doc_name}", - source_project_display_name: "Project Two" }, (error, response, body) => new_file_id = body.new_file_id @existing_file_id = new_file_id @@ -114,6 +113,12 @@ describe "LinkedFiles", -> return done(error) if error? firstFile = project.rootFolder[0].fileRefs[0] expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.linkedFileData).to.deep.equal { + provider: 'project_file', + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + source_project_display_name: "plf-test-two" + } expect(firstFile.name).to.equal('test-link.txt') done() @@ -127,7 +132,6 @@ describe "LinkedFiles", -> data: source_project_id: @project_two_id, source_entity_path: "/#{@source_doc_name}", - source_project_display_name: "Project Two" }, (error, response, body) => new_file_id = body.new_file_id expect(new_file_id).to.exist From 9e65e5e8130051aa9e6d9563fe2722185ecd13d5 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Tue, 29 May 2018 10:46:22 +0100 Subject: [PATCH 073/135] Fix loading of Rich Text page in Test Controls --- .../test-controls/controllers/TestControlsController.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee index 65378a2cde..ae7db45905 100644 --- a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee +++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee @@ -11,4 +11,6 @@ define [ window.openLinkedFileModal() $scope.richText = () -> - window.location.href = window.location.toString() + '&rt=true' + current = window.location.toString() + target = "#{current}#{if window.location.search then '&' else '?'}rt=true" + window.location.href = target From 8d2189f843df67e077610906a3ef1831adecdb59 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 29 May 2018 16:50:15 +0100 Subject: [PATCH 074/135] Support using both point-in-time and compare modes. --- services/web/app/views/project/editor.pug | 2 +- .../project/editor/history-file-tree.pug | 32 +++++- .../project/editor/history/entriesListV2.pug | 108 ++++++++++++++---- .../project/editor/history/previewPanelV2.pug | 7 +- .../project/editor/history/toolbarV2.pug | 12 +- .../ide/history/HistoryV2Manager.coffee | 29 +++-- .../stylesheets/app/editor/history-v2.less | 20 +++- .../stylesheets/app/editor/history.less | 5 +- .../stylesheets/core/_common-variables.less | 6 +- .../public/stylesheets/core/ol-variables.less | 2 + 10 files changed, 174 insertions(+), 49 deletions(-) diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 45600c4a8f..b34b9f690d 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -62,7 +62,7 @@ block content main#ide-body( ng-cloak, role="main", - ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2) }", + ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }", layout="main", ng-hide="state.loading", resize-on="layout:chat:resize", diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug index 9d9a784678..f7d4afb18d 100644 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ b/services/web/app/views/project/editor/history-file-tree.pug @@ -1,6 +1,6 @@ aside.file-tree.full-size( ng-controller="HistoryV2FileTreeController" - ng-if="ui.view == 'history' && history.isV2" + ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" ) .history-file-tree-inner history-file-tree( @@ -10,6 +10,32 @@ aside.file-tree.full-size( is-loading="history.loadingFileTree" ) + + +aside.file-tree.file-tree-history.full-size( + ng-controller="FileTreeController" + ng-class="{ 'multi-selected': multiSelectedCount > 0 }" + ng-show="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.COMPARE") + .toolbar.toolbar-filetree + span Modified files + + .file-tree-inner + ul.list-unstyled.file-tree-list + li( + ng-repeat="(pathname, doc) in history.selection.docs" + ng-class="{ 'selected': history.selection.pathname == pathname }" + ) + .entity + .entity-name.entity-name-history( + ng-click="history.selection.pathname = pathname", + ng-class="{ 'deleted': !!doc.deletedAtV }" + ) + i.fa.fa-fw.fa-pencil + span {{ pathname }} + + + + script(type="text/ng-template", id="historyFileTreeTpl") .history-file-tree history-file-entity( @@ -17,9 +43,7 @@ script(type="text/ng-template", id="historyFileTreeTpl") file-entity="fileEntity" ng-show="!$ctrl.isLoading" ) - .loading(ng-show="$ctrl.isLoading") - i.fa.fa-spin.fa-refresh - |    #{translate("loading")}... + script(type="text/ng-template", id="historyFileEntityTpl") .history-file-entity-wrapper diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index 6a9c75e08f..0256824293 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -1,5 +1,5 @@ aside.change-list( - ng-if="history.isV2" + ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" ng-controller="HistoryV2ListController" ) history-entries-list( @@ -12,6 +12,89 @@ aside.change-list( on-entry-select="handleEntrySelect(selectedEntry)" ) +aside.change-list( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE" + ng-controller="HistoryListController" + infinite-scroll="loadMore()" + infinite-scroll-disabled="history.loading || history.atEnd" + infinite-scroll-initialize="ui.view == 'history'" +) + .infinite-scroll-inner + ul.list-unstyled( + ng-class="{\ + 'hover-state': history.hoveringOverListSelectors\ + }" + ) + li.change( + ng-repeat="update in history.updates" + ng-class="{\ + 'first-in-day': update.meta.first_in_day,\ + 'selected': update.inSelection,\ + 'selected-to': update.selectedTo,\ + 'selected-from': update.selectedFrom,\ + 'hover-selected': update.inHoverSelection,\ + 'hover-selected-to': update.hoverSelectedTo,\ + 'hover-selected-from': update.hoverSelectedFrom,\ + }" + ng-controller="HistoryListItemController" + ) + + div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} + + div.selectors + div.range + form + input.selector-from( + type="radio" + name="fromVersion" + ng-model="update.selectedFrom" + ng-value="true" + ng-mouseover="mouseOverSelectedFrom()" + ng-mouseout="mouseOutSelectedFrom()" + ng-show="update.afterSelection || update.inSelection" + ) + form + input.selector-to( + type="radio" + name="toVersion" + ng-model="update.selectedTo" + ng-value="true" + ng-mouseover="mouseOverSelectedTo()" + ng-mouseout="mouseOutSelectedTo()" + ng-show="update.beforeSelection || update.inSelection" + ) + + div.description(ng-click="select()") + div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} + div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") + | Edited + div.docs(ng-repeat="pathname in update.pathnames") + .doc {{ pathname }} + div.docs(ng-repeat="project_op in update.project_ops") + div(ng-if="project_op.rename") + .action Renamed + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action Created + .doc {{ project_op.add.pathname }} + div(ng-if="project_op.remove") + .action Deleted + .doc {{ project_op.remove.pathname }} + div.users + div.user(ng-repeat="update_user in update.meta.users") + .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") + .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") + .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") + .name(ng-if="update_user && update_user.id == user.id") You + .name(ng-if="update_user == null") #{translate("anonymous")} + div.user(ng-if="update.meta.users.length == 0") + .color-square(style="background-color: hsl(100, 100%, 50%)") + span #{translate("anonymous")} + + .loading(ng-show="history.loading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... + script(type="text/ng-template", id="historyEntriesListTpl") .history-entries( infinite-scroll="$ctrl.loadEntries()" @@ -45,29 +128,6 @@ script(type="text/ng-template", id="historyEntryTpl") time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }} - //- div.selectors - //- div.range - //- form - //- input.selector-from( - //- type="radio" - //- name="fromVersion" - //- ng-model="$ctrl.entry.selectedFrom" - //- ng-value="true" - //- ng-mouseover="mouseOverSelectedFrom()" - //- ng-mouseout="mouseOutSelectedFrom()" - //- ng-show="$ctrl.entry.afterSelection || $ctrl.entry.inSelection" - //- ) - //- form - //- input.selector-to( - //- type="radio" - //- name="toVersion" - //- ng-model="$ctrl.entry.selectedTo" - //- ng-value="true" - //- ng-mouseover="mouseOverSelectedTo()" - //- ng-mouseout="mouseOutSelectedTo()" - //- ng-show="$ctrl.entry.beforeSelection || $ctrl.entry.inSelection" - ) - .history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })") ol.history-entry-changes li.history-entry-change( diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index 597dc8b0bf..caca523f14 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -19,8 +19,13 @@ }" ) | in {{history.diff.pathname}} + .btn.btn-info.btn-xs( + ng-click="toggleHistoryViewMode();" + ) + i.fa + | Enter "Point-in-time" mode .toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV") - button.btn.btn-danger.btn-sm( + button.btn.btn-danger.btn-xs( ng-click="restoreDeletedFile()" ng-show="!restoreState.error" ng-disabled="restoreState.inflight" diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index 58b94dcaf2..2b1b344324 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -1,5 +1,13 @@ .history-toolbar( - ng-if="ui.view == 'history' && history.isV2" + ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" ) + span(ng-show="history.loadingFileTree") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... span(ng-show="!history.loadingFileTree") Browsing project as of  - time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} \ No newline at end of file + time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} + .history-toolbar-btn( + ng-click="toggleHistoryViewMode();" + ) + i.fa + | Enter "Compare" mode \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 1bc533d665..7c8f476e39 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -23,21 +23,31 @@ define [ else @show() - # @$scope.$watch "history.selection.updates", (updates) => - # if updates? and updates.length > 0 - # @_selectDocFromUpdates() - # @reloadDiff() + @$scope.toggleHistoryViewMode = () => + if @$scope.history.viewMode == HistoryViewModes.COMPARE + @reset() + @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME + else + @reset() + @$scope.history.viewMode = HistoryViewModes.COMPARE - # @$scope.$watch "history.selection.pathname", () => - # @reloadDiff() + @$scope.$watch "history.selection.updates", (updates) => + if @$scope.history.viewMode == HistoryViewModes.COMPARE + if updates? and updates.length > 0 + @_selectDocFromUpdates() + @reloadDiff() @$scope.$watch "history.selection.pathname", (pathname) => - if pathname? - @loadFileAtPointInTime() + if @$scope.history.viewMode == HistoryViewModes.POINT_IN_TIME + if pathname? + @loadFileAtPointInTime() + else + @reloadDiff() show: () -> @$scope.ui.view = "history" @reset() + @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME hide: () -> @$scope.ui.view = "editor" @@ -46,7 +56,7 @@ define [ @$scope.history = { isV2: true updates: [] - viewMode: HistoryViewModes.POINT_IN_TIME + viewMode: null nextBeforeTimestamp: null atEnd: false selection: { @@ -146,7 +156,6 @@ define [ @$scope.history.selectedFile.binary = binary @$scope.history.selectedFile.text = text @$scope.history.selectedFile.loading = false - console.log @$scope.history.selectedFile .catch () -> reloadDiff: () -> diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 478f939d71..f3d92a2838 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -1,17 +1,31 @@ .history-toolbar { + display: flex; + align-items: center; position: absolute; width: 100%; top: @ide-body-top-offset; height: @editor-toolbar-height; - line-height: @editor-toolbar-height; - background-color: @editor-toolbar-bg; + line-height: 1; + font-size: @font-size-small; + background-color: @history-toolbar-bg-color; z-index: 1; - color: #FFF; + color: @history-toolbar-color; padding-left: (@line-height-computed / 2); +} +.history-toolbar when (@is-overleaf = false) { + border-bottom: @toolbar-border-bottom; } .history-toolbar-time { font-weight: bold; } + .history-toolbar-btn { + .btn; + .btn-info; + .btn-xs; + padding-left: @padding-small-horizontal; + padding-right: @padding-small-horizontal; + margin-left: (@line-height-computed / 2); + } .history-entries { font-size: @history-base-font-size; diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index ba4e1e142e..a8d876bc52 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -1,4 +1,4 @@ -@changesListWidth: 250px; +@changesListWidth: 250px; @changesListPadding: @line-height-computed / 2; @selector-padding-vertical: 10px; @@ -50,6 +50,7 @@ .full-size; .toolbar { padding: 3px; + height: 32px; .name { float: left; padding: 3px @line-height-computed / 4; @@ -58,7 +59,7 @@ } .diff-editor { .full-size; - top: 40px; + top: 32px; } .diff-deleted { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 543e169b1e..cdee262789 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -978,7 +978,9 @@ // v2 History @history-base-font-size : @font-size-small; @history-base-bg : @gray-lightest; -@history-entry-day-bg : @gray-dark; +@history-entry-day-bg : @gray; @history-entry-selected-bg : @red; @history-base-color : @gray-light; -@history-highlight-color : @gray; \ No newline at end of file +@history-highlight-color : @gray; +@history-toolbar-bg-color : @toolbar-alt-bg-color; +@history-toolbar-color : @text-color; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index cd5b035fc4..61e7a93498 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -273,6 +273,8 @@ @history-entry-selected-bg : @ol-green; @history-base-color : @ol-blue-gray-2; @history-highlight-color : @ol-type-color; +@history-toolbar-bg-color : @editor-toolbar-bg; +@history-toolbar-color : #FFF; // System messages @sys-msg-background : @ol-blue; From 301ae80f99a05869fe587b0b1b801baca5578af2 Mon Sep 17 00:00:00 2001 From: James Allen Date: Tue, 29 May 2018 17:21:42 +0100 Subject: [PATCH 075/135] Allow refreshFeatures to notify v1 to update its features --- .../Subscription/FeaturesUpdater.coffee | 15 ++++- .../Subscription/V1SubscriptionManager.coffee | 67 +++++++++++-------- ...ests.coffee => FeatureUpdaterTests.coffee} | 37 ++++++---- .../coffee/helpers/MockV1Api.coffee | 13 +++- .../V1SusbcriptionManagerTests.coffee | 60 ++++++++--------- 5 files changed, 114 insertions(+), 78 deletions(-) rename services/web/test/acceptance/coffee/{SubscriptionTests.coffee => FeatureUpdaterTests.coffee} (85%) diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index 5c176c611f..28da0ef2f7 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -11,7 +11,16 @@ V1SubscriptionManager = require("./V1SubscriptionManager") oneMonthInSeconds = 60 * 60 * 24 * 30 module.exports = FeaturesUpdater = - refreshFeatures: (user_id, callback)-> + refreshFeatures: (user_id, notifyV1, callback)-> + if !callback? + callback = notifyV1 + notifyV1 = false + + if notifyV1 + V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) -> + if error? + logger.err {err: error, user_id}, "error notifying v1 about updated features" + jobs = individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb @@ -80,4 +89,6 @@ module.exports = FeaturesUpdater = if !plan? return {} else - return plan.features \ No newline at end of file + return plan.features + + _notifyV1: (user_id, callback = (error) ->) -> diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee index 05dc140be2..f297984dd3 100644 --- a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee +++ b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee @@ -12,39 +12,50 @@ module.exports = V1SubscriptionManager = # - 'v1_free' getPlanCodeFromV1: (userId, callback=(err, planCode)->) -> logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user" + V1SubscriptionManager._v1Request userId, { + method: 'GET', + url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/plan_code" + }, (error, body) -> + return callback(error) if error? + planName = body?.plan_name + logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user" + if planName in ['pro', 'pro_plus', 'student', 'free'] + planName = "v1_#{planName}" + else + # Throw away 'anonymous', etc as being equivalent to null + planName = null + return callback(null, planName) + + notifyV1OfFeaturesChange: (userId, callback = (error) ->) -> + V1SubscriptionManager._v1Request userId, { + method: 'POST', + url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/sync" + }, callback + + _v1Request: (userId, options, callback=(err, body)->) -> + if !settings?.apis?.v1 + return callback null, null UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) -> return callback(err) if err? v1Id = user?.overleaf?.id if !v1Id? logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user" return callback(null, null) - V1SubscriptionManager._v1PlanRequest v1Id, (err, body) -> - return callback(err) if err? - planName = body?.plan_name - logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user" - if planName in ['pro', 'pro_plus', 'student', 'free'] - planName = "v1_#{planName}" + options.url = options.url + request { + baseUrl: settings.apis.v1.url + url: options.url(v1Id) + method: options.method + auth: + user: settings.apis.v1.user + pass: settings.apis.v1.pass + sendImmediately: true + json: true, + timeout: 5 * 1000 + }, (error, response, body) -> + return callback(error) if error? + if 200 <= response.statusCode < 300 + return callback null, body else - # Throw away 'anonymous', etc as being equivalent to null - planName = null - return callback(null, planName) + return callback new Error("non-success code from v1: #{response.statusCode}") - _v1PlanRequest: (v1Id, callback=(err, body)->) -> - if !settings?.apis?.v1 - return callback null, null - request { - method: 'GET', - url: settings.apis.v1.url + - "/api/v1/sharelatex/users/#{v1Id}/plan_code" - auth: - user: settings.apis.v1.user - pass: settings.apis.v1.pass - sendImmediately: true - json: true, - timeout: 5 * 1000 - }, (error, response, body) -> - return callback(error) if error? - if 200 <= response.statusCode < 300 - return callback null, body - else - return callback new Error("non-success code from v1: #{response.statusCode}") \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/SubscriptionTests.coffee b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee similarity index 85% rename from services/web/test/acceptance/coffee/SubscriptionTests.coffee rename to services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee index ce762ea0ee..3264f4aeae 100644 --- a/services/web/test/acceptance/coffee/SubscriptionTests.coffee +++ b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee @@ -6,27 +6,22 @@ settings = require "settings-sharelatex" {ObjectId} = require("../../../app/js/infrastructure/mongojs") Subscription = require("../../../app/js/models/Subscription").Subscription User = require("../../../app/js/models/User").User +FeaturesUpdater = require("../../../app/js/Features/Subscription/FeaturesUpdater") MockV1Api = require "./helpers/MockV1Api" +logger = require "logger-sharelatex" +logger.logger.level("error") syncUserAndGetFeatures = (user, callback = (error, features) ->) -> - request { - method: 'POST', - url: "/user/#{user._id}/features/sync", - auth: - user: 'sharelatex' - pass: 'password' - sendImmediately: true - }, (error, response, body) -> - throw error if error? - expect(response.statusCode).to.equal 200 + FeaturesUpdater.refreshFeatures user._id, (error) -> + return callback(error) if error? User.findById user._id, (error, user) -> return callback(error) if error? features = user.toObject().features delete features.$init # mongoose internals return callback null, features -describe "Subscriptions", -> +describe "FeatureUpdater.refreshFeatures", -> beforeEach (done) -> @user = new UserClient() @user.ensureUserExists (error) -> @@ -148,4 +143,22 @@ describe "Subscriptions", -> throw error if error? plan = settings.plans.find (plan) -> plan.planCode == 'professional' expect(features).to.deep.equal(plan.features) - done() \ No newline at end of file + done() + + describe "when the notifyV1Flag is passed", -> + beforeEach -> + User.update { + _id: @user._id + }, { + overleaf: + id: 42 + } # returns a promise + + it "should ping the v1 API end point to sync", (done) -> + FeaturesUpdater.refreshFeatures @user._id, true, (error) => + setTimeout () => + expect( + MockV1Api.syncUserFeatures.calledWith('42') + ).to.equal true + done() + , 500 diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index 5c2cf47ad9..389ecd1762 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -1,6 +1,7 @@ express = require("express") app = express() bodyParser = require('body-parser') +sinon = require 'sinon' app.use(bodyParser.json()) @@ -23,19 +24,25 @@ module.exports = MockV1Api = clearExportParams: () -> @exportParams = null + syncUserFeatures: sinon.stub() + run: () -> - app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) => - user = @users[req.params.ol_user_id] + app.get "/api/v1/sharelatex/users/:v1_user_id/plan_code", (req, res, next) => + user = @users[req.params.v1_user_id] if user res.json user else res.sendStatus 404 + app.post "/api/v1/sharelatex/users/:v1_user_id/sync", (req, res, next) => + @syncUserFeatures(req.params.v1_user_id) + res.sendStatus 200 + app.post "/api/v1/sharelatex/exports", (req, res, next) => - #{project, version, pathname} @exportParams = Object.assign({}, req.body) res.json exportId: @exportId + app.listen 5000, (error) -> throw error if error? .on "error", (error) -> diff --git a/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee b/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee index ae6237e627..9d85d81f84 100644 --- a/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee @@ -16,10 +16,10 @@ describe 'V1SubscriptionManager', -> err: sinon.stub() warn: sinon.stub() "settings-sharelatex": - overleaf: - host: @host = "http://overleaf.example.com" + apis: + v1: + host: @host = "http://overleaf.example.com" "request": @request = sinon.stub() - @V1SubscriptionManager._v1PlanRequest = sinon.stub() @userId = 'abcd' @v1UserId = 42 @user = @@ -33,33 +33,20 @@ describe 'V1SubscriptionManager', -> @responseBody = id: 32, plan_name: 'pro' - @UserGetter.getUser = sinon.stub() - .yields(null, @user) - @V1SubscriptionManager._v1PlanRequest = sinon.stub() + @V1SubscriptionManager._v1Request = sinon.stub() .yields(null, @responseBody) @call = (cb) => @V1SubscriptionManager.getPlanCodeFromV1 @userId, cb describe 'when all goes well', -> - - it 'should call getUser', (done) -> + it 'should call _v1Request', (done) -> @call (err, planCode) => expect( - @UserGetter.getUser.callCount + @V1SubscriptionManager._v1Request.callCount ).to.equal 1 expect( - @UserGetter.getUser.calledWith(@userId) - ).to.equal true - done() - - it 'should call _v1PlanRequest', (done) -> - @call (err, planCode) => - expect( - @V1SubscriptionManager._v1PlanRequest.callCount - ).to.equal 1 - expect( - @V1SubscriptionManager._v1PlanRequest.calledWith( - @v1UserId + @V1SubscriptionManager._v1Request.calledWith( + @userId ) ).to.equal true done() @@ -80,49 +67,56 @@ describe 'V1SubscriptionManager', -> expect(planCode).to.equal null done() + describe '_v1Request', -> + beforeEach -> + @UserGetter.getUser = sinon.stub() + .yields(null, @user) + describe 'when getUser produces an error', -> beforeEach -> @UserGetter.getUser = sinon.stub() .yields(new Error('woops')) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb - it 'should not call _v1PlanRequest', (done) -> + it 'should not call request', (done) -> @call (err, planCode) => expect( - @V1SubscriptionManager._v1PlanRequest.callCount + @request.callCount ).to.equal 0 done() it 'should produce an error', (done) -> @call (err, planCode) => expect(err).to.exist - expect(planCode).to.not.exist done() describe 'when getUser does not find a user', -> beforeEach -> @UserGetter.getUser = sinon.stub() .yields(null, null) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb - it 'should not call _v1PlanRequest', (done) -> + it 'should not call request', (done) -> @call (err, planCode) => expect( - @V1SubscriptionManager._v1PlanRequest.callCount + @request.callCount ).to.equal 0 done() - it 'should produce a null plan-code, without error', (done) -> - @call (err, planCode) => + it 'should not error', (done) -> + @call (err) => expect(err).to.not.exist - expect(planCode).to.not.exist done() describe 'when the request to v1 fails', -> beforeEach -> - @V1SubscriptionManager._v1PlanRequest = sinon.stub() - .yields(new Error('woops')) + @request.yields(new Error('woops')) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb it 'should produce an error', (done) -> - @call (err, planCode) => + @call (err) => expect(err).to.exist - expect(planCode).to.not.exist done() From c5b553d4a69a87ce22e4c6e87a32bea968d614a2 Mon Sep 17 00:00:00 2001 From: James Allen Date: Tue, 29 May 2018 17:31:15 +0100 Subject: [PATCH 076/135] Notify v1 by default --- .../Subscription/FeaturesUpdater.coffee | 2 +- .../coffee/FeatureUpdaterTests.coffee | 2 +- .../Subscription/FeaturesUpdaterTests.coffee | 80 +++++++++++-------- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index 28da0ef2f7..3a505167d7 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -14,7 +14,7 @@ module.exports = FeaturesUpdater = refreshFeatures: (user_id, notifyV1, callback)-> if !callback? callback = notifyV1 - notifyV1 = false + notifyV1 = true if notifyV1 V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) -> diff --git a/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee index 3264f4aeae..5bc3026fff 100644 --- a/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee +++ b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee @@ -13,7 +13,7 @@ logger = require "logger-sharelatex" logger.logger.level("error") syncUserAndGetFeatures = (user, callback = (error, features) ->) -> - FeaturesUpdater.refreshFeatures user._id, (error) -> + FeaturesUpdater.refreshFeatures user._id, false, (error) -> return callback(error) if error? User.findById user._id, (error, user) -> return callback(error) if error? diff --git a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee index c8f77bfd40..489c5676ff 100644 --- a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee @@ -22,6 +22,7 @@ describe "FeaturesUpdater", -> describe "refreshFeatures", -> beforeEach -> + @V1SubscriptionManager.notifyV1OfFeaturesChange = sinon.stub().yields() @UserFeaturesUpdater.updateFeatures = sinon.stub().yields() @FeaturesUpdater._getIndividualFeatures = sinon.stub().yields(null, { 'individual': 'features' }) @FeaturesUpdater._getGroupFeatureSets = sinon.stub().yields(null, [{ 'group': 'features' }, { 'group': 'features2' }]) @@ -29,48 +30,63 @@ describe "FeaturesUpdater", -> @ReferalFeatures.getBonusFeatures = sinon.stub().yields(null, { 'bonus': 'features' }) @FeaturesUpdater._mergeFeatures = sinon.stub().returns({'merged': 'features'}) @callback = sinon.stub() - @FeaturesUpdater.refreshFeatures @user_id, @callback - it "should get the individual features", -> - @FeaturesUpdater._getIndividualFeatures - .calledWith(@user_id) - .should.equal true + describe "normally", -> + beforeEach -> + @FeaturesUpdater.refreshFeatures @user_id, @callback - it "should get the group features", -> - @FeaturesUpdater._getGroupFeatureSets - .calledWith(@user_id) - .should.equal true + it "should get the individual features", -> + @FeaturesUpdater._getIndividualFeatures + .calledWith(@user_id) + .should.equal true - it "should get the v1 features", -> - @FeaturesUpdater._getV1Features - .calledWith(@user_id) - .should.equal true + it "should get the group features", -> + @FeaturesUpdater._getGroupFeatureSets + .calledWith(@user_id) + .should.equal true - it "should get the bonus features", -> - @ReferalFeatures.getBonusFeatures - .calledWith(@user_id) - .should.equal true + it "should get the v1 features", -> + @FeaturesUpdater._getV1Features + .calledWith(@user_id) + .should.equal true - it "should merge from the default features", -> - @FeaturesUpdater._mergeFeatures.calledWith(@Settings.defaultFeatures).should.equal true + it "should get the bonus features", -> + @ReferalFeatures.getBonusFeatures + .calledWith(@user_id) + .should.equal true - it "should merge the individual features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'individual': 'features' }).should.equal true + it "should merge from the default features", -> + @FeaturesUpdater._mergeFeatures.calledWith(@Settings.defaultFeatures).should.equal true - it "should merge the group features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true + it "should merge the individual features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'individual': 'features' }).should.equal true - it "should merge the v1 features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true + it "should merge the group features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true - it "should merge the bonus features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'bonus': 'features' }).should.equal true + it "should merge the v1 features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true - it "should update the user with the merged features", -> - @UserFeaturesUpdater.updateFeatures - .calledWith(@user_id, {'merged': 'features'}) - .should.equal true + it "should merge the bonus features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'bonus': 'features' }).should.equal true + + it "should update the user with the merged features", -> + @UserFeaturesUpdater.updateFeatures + .calledWith(@user_id, {'merged': 'features'}) + .should.equal true + + it "should notify v1", -> + @V1SubscriptionManager.notifyV1OfFeaturesChange + .called.should.equal true + + describe "with notifyV1 == false", -> + beforeEach -> + @FeaturesUpdater.refreshFeatures @user_id, false, @callback + + it "should not notify v1", -> + @V1SubscriptionManager.notifyV1OfFeaturesChange + .called.should.equal false describe "_mergeFeatures", -> it "should prefer priority over standard for compileGroup", -> From c6d2b4f1e79159097200920fdb5b32a668b4916a Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 25 May 2018 13:46:54 +0100 Subject: [PATCH 077/135] Use single rich text include instead of split toolbar & body includes --- services/web/app/views/project/editor/editor.pug | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index 3cac3a9490..184b7e854b 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -33,7 +33,7 @@ div.full-size( i.fa.fa-arrow-left |   #{translate("open_a_file_on_the_left")} - != moduleIncludes('editor:toolbar', locals) + != moduleIncludes('editor:main', locals) #editor( ace-editor="editor", @@ -73,8 +73,6 @@ div.full-size( line-height="settings.lineHeight || ui.defaultLineHeight" ) - != moduleIncludes('editor:body', locals) - include ./review-panel .ui-layout-east From d0b160d9a2b403dfd5ea478071aa397749867332 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 25 May 2018 13:48:35 +0100 Subject: [PATCH 078/135] Rename flag for clarity --- services/web/app/views/project/editor/editor.pug | 2 +- services/web/public/coffee/ide/editor/EditorManager.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index 184b7e854b..c24d967da7 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -37,7 +37,7 @@ div.full-size( #editor( ace-editor="editor", - ng-if="!editor.richText", + ng-if="!editor.showRichText", ng-show="!!editor.sharejs_doc && !editor.opening", style=showRichText ? "top: 32px" : "", theme="settings.theme", diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index e3cabf8e98..7246e09b83 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -14,7 +14,7 @@ define [ opening: true trackChanges: false wantTrackChanges: false - richText: false + showRichText: false } @$scope.$on "entity:selected", (event, entity) => From e0e88b25fe174c5f7d16862904e6ce649310b942 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 30 May 2018 12:48:08 +0100 Subject: [PATCH 079/135] Make agrument checking more robust --- .../app/coffee/Features/Subscription/FeaturesUpdater.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index 3a505167d7..dc7571ea50 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -11,8 +11,8 @@ V1SubscriptionManager = require("./V1SubscriptionManager") oneMonthInSeconds = 60 * 60 * 24 * 30 module.exports = FeaturesUpdater = - refreshFeatures: (user_id, notifyV1, callback)-> - if !callback? + refreshFeatures: (user_id, notifyV1 = true, callback = () ->)-> + if typeof notifyV1 == 'function' callback = notifyV1 notifyV1 = true From 4c4a4f10c13e1c19046dee4d3cac51e6cd1a48a5 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 14:21:01 +0100 Subject: [PATCH 080/135] Rename history file tree and move it around. --- services/web/app/views/project/editor.pug | 2 +- .../editor/{history-file-tree.pug => history/fileTreeV2.pug} | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) rename services/web/app/views/project/editor/{history-file-tree.pug => history/fileTreeV2.pug} (99%) diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 3b611440dd..602b9af86b 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -73,7 +73,7 @@ block content ) .ui-layout-west include ./editor/file-tree - include ./editor/history-file-tree + include ./editor/history/fileTreeV2 .ui-layout-center include ./editor/editor diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history/fileTreeV2.pug similarity index 99% rename from services/web/app/views/project/editor/history-file-tree.pug rename to services/web/app/views/project/editor/history/fileTreeV2.pug index f7d4afb18d..0f3a2c1203 100644 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ b/services/web/app/views/project/editor/history/fileTreeV2.pug @@ -10,8 +10,6 @@ aside.file-tree.full-size( is-loading="history.loadingFileTree" ) - - aside.file-tree.file-tree-history.full-size( ng-controller="FileTreeController" ng-class="{ 'multi-selected': multiSelectedCount > 0 }" From aec4ea79add97325df7cecbe27b13c8986b726a5 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 15:00:20 +0100 Subject: [PATCH 081/135] Make history file tree more like the editor file tree in SL. --- .../stylesheets/app/editor/history-v2.less | 17 +++++++++++++++++ .../stylesheets/core/_common-variables.less | 17 +++++++++-------- .../public/stylesheets/core/ol-variables.less | 16 ++++++++-------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index f3d92a2838..abb92d72b6 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -105,6 +105,11 @@ font-family: @font-family-serif; } } + +.history-file-tree-inner when (@is-overleaf = false) { + font-size: 0.8rem; +} + .history-file-entity-wrapper { color: #FFF; margin-left: (@line-height-computed / 2); @@ -132,6 +137,7 @@ background-color: @file-tree-item-selected-bg; font-weight: bold; padding-right: 32px; + color: #FFF; .fake-full-width-bg(@file-tree-item-selected-bg); &:hover { background-color: @file-tree-item-hover-bg; @@ -152,6 +158,17 @@ overflow: hidden; text-overflow: ellipsis; } + + .history-file-entity-link-selected when (@is-overleaf = false) { + color: @brand-primary; + &:hover, + &:focus { + color: @brand-primary; + } + .history-file-entity-icon { + color: @brand-primary; + } + } // @changesListWidth: 250px; // @changesListPadding: @line-height-computed / 2; diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index cdee262789..033ce9c1bf 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -976,11 +976,12 @@ @sys-msg-border : 1px solid @common-border-color; // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @gray-lightest; -@history-entry-day-bg : @gray; -@history-entry-selected-bg : @red; -@history-base-color : @gray-light; -@history-highlight-color : @gray; -@history-toolbar-bg-color : @toolbar-alt-bg-color; -@history-toolbar-color : @text-color; +@history-base-font-size : @font-size-small; +@history-base-bg : @gray-lightest; +@history-entry-day-bg : @gray; +@history-entry-selected-bg : @red; +@history-base-color : @gray-light; +@history-highlight-color : @gray; +@history-toolbar-bg-color : @toolbar-alt-bg-color; +@history-toolbar-color : @text-color; + diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 61e7a93498..3d3d4468fd 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -267,14 +267,14 @@ // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @ol-blue-gray-1; -@history-entry-day-bg : @ol-blue-gray-2; -@history-entry-selected-bg : @ol-green; -@history-base-color : @ol-blue-gray-2; -@history-highlight-color : @ol-type-color; -@history-toolbar-bg-color : @editor-toolbar-bg; -@history-toolbar-color : #FFF; +@history-base-font-size : @font-size-small; +@history-base-bg : @ol-blue-gray-1; +@history-entry-day-bg : @ol-blue-gray-2; +@history-entry-selected-bg : @ol-green; +@history-base-color : @ol-blue-gray-2; +@history-highlight-color : @ol-type-color; +@history-toolbar-bg-color : @editor-toolbar-bg; +@history-toolbar-color : #FFF; // System messages @sys-msg-background : @ol-blue; From 66d7bdb26b747978566438af1cff02065880fec8 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 15:03:22 +0100 Subject: [PATCH 082/135] Change buttons copy. --- .../web/app/views/project/editor/history/previewPanelV2.pug | 2 +- services/web/app/views/project/editor/history/toolbarV2.pug | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index caca523f14..89a89c0e23 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -23,7 +23,7 @@ ng-click="toggleHistoryViewMode();" ) i.fa - | Enter "Point-in-time" mode + | Browse project versions .toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV") button.btn.btn-danger.btn-xs( ng-click="restoreDeletedFile()" diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index 2b1b344324..48f2292a16 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -10,4 +10,4 @@ ng-click="toggleHistoryViewMode();" ) i.fa - | Enter "Compare" mode \ No newline at end of file + | Compare project versions \ No newline at end of file From 7cb4280a4d59e1cfb9e3cdea965f93923aa67499 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 15:18:15 +0100 Subject: [PATCH 083/135] Remove commented-out code. --- .../HistoryV2ListController.coffee | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee index 823461d16a..eaf7fbc884 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -73,43 +73,4 @@ define [ $scope.$watch "history.updates.length", () -> $scope.recalculateSelectedUpdates() - ] - - # App.controller "HistoryListItemController", ["$scope", "event_tracking", ($scope, event_tracking) -> - # $scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) -> - # if selectedFrom - # for update in $scope.history.updates - # update.selectedFrom = false unless update == $scope.update - # $scope.recalculateSelectedUpdates() - - # $scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) -> - # if selectedTo - # for update in $scope.history.updates - # update.selectedTo = false unless update == $scope.update - # $scope.recalculateSelectedUpdates() - - # $scope.select = () -> - # event_tracking.sendMB "history-view-change" - # $scope.update.selectedTo = true - # $scope.update.selectedFrom = true - - # $scope.mouseOverSelectedFrom = () -> - # $scope.history.hoveringOverListSelectors = true - # $scope.update.hoverSelectedFrom = true - # $scope.recalculateHoveredUpdates() - - # $scope.mouseOutSelectedFrom = () -> - # $scope.history.hoveringOverListSelectors = false - # $scope.resetHoverState() - - # $scope.mouseOverSelectedTo = () -> - # $scope.history.hoveringOverListSelectors = true - # $scope.update.hoverSelectedTo = true - # $scope.recalculateHoveredUpdates() - - # $scope.mouseOutSelectedTo = () -> - # $scope.history.hoveringOverListSelectors = false - # $scope.resetHoverState() - - # $scope.displayName = displayNameForUser - # ] + ] \ No newline at end of file From a747480425b79eacaea6d329a2b4171ec1821546 Mon Sep 17 00:00:00 2001 From: Henry Oswald Date: Wed, 30 May 2018 15:28:59 +0100 Subject: [PATCH 084/135] add references host into settings --- .../app/coffee/Features/References/ReferencesHandler.coffee | 2 ++ services/web/config/settings.defaults.coffee | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee index 8728896631..959833351f 100644 --- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee +++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee @@ -10,6 +10,8 @@ Async = require('async') oneMinInMs = 60 * 1000 fiveMinsInMs = oneMinInMs * 5 +if !settings.apis?.references?.url? + logger.log "references search not enabled" module.exports = ReferencesHandler = diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 0892804778..7f4024c368 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -146,8 +146,8 @@ module.exports = settings = url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036" sixpack: url: "" - # references: - # url: "http://localhost:3040" + references: + url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040" notifications: url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042" analytics: From 6e7e76a3ce5243f02ca13faaf807429b5c7749dd Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 17:34:46 +0100 Subject: [PATCH 085/135] Margin and padding adjustments. --- .../web/app/views/project/editor/history/previewPanelV2.pug | 2 +- services/web/public/stylesheets/app/editor/history.less | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index 89a89c0e23..596912d109 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -19,7 +19,7 @@ }" ) | in {{history.diff.pathname}} - .btn.btn-info.btn-xs( + .history-toolbar-btn( ng-click="toggleHistoryViewMode();" ) i.fa diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index a8d876bc52..3e558b6c5f 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -88,6 +88,7 @@ .loading { text-align: center; font-family: @font-family-serif; + margin-top: (@line-height-computed / 2); } ul { From 4f5148e668117dfb209f1b939b0d5a8bf164c923 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 30 May 2018 17:55:02 +0100 Subject: [PATCH 086/135] Wrap text like normal --- .../web/app/views/project/editor/history/entriesListV2.pug | 5 ++++- services/web/public/stylesheets/app/editor/history-v2.less | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index 0256824293..b17adbce67 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -142,7 +142,10 @@ script(type="text/ng-template", id="historyEntryTpl") span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} .history-entry-metadata time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} - span  •  + span + | + | • + | ol.history-entry-metadata-users li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users") span.name( diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index abb92d72b6..9089ed1ae9 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -58,7 +58,7 @@ margin-bottom: 3px; } .history-entry-change { - display: flex; + } .history-entry-change-action { margin-right: 0.5em; @@ -76,7 +76,7 @@ } .history-entry-metadata-time { - + white-space: nowrap; } .history-entry-metadata-users { From 20cca0fcd4d303e1813dae8a3979e4723044968f Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 16 May 2018 16:53:33 +0100 Subject: [PATCH 087/135] Add action buttons to project list --- services/web/app/views/project/list/item.pug | 23 +++++++++++++++++-- .../app/views/project/list/project-list.pug | 6 +++-- .../web/app/views/project/list/v1-item.pug | 4 ++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index bfc53a8360..b2fc9390de 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -1,4 +1,4 @@ -.col-xs-6 +.col-xs-6.col-sm-4.col-md-6 input.select-item( select-individual, type="checkbox", @@ -37,8 +37,27 @@ tooltip-placement="right" tooltip-append-to-body="true" ) -.col-xs-4 +.col-xs-4.col-sm-3.col-md-2 if settings.overleaf span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} else span.last-modified {{project.lastUpdated | formatDate}} +.hidden-xs.col-sm-3.col-md-2 + button.btn.btn-link.action-btn( + tooltip=translate('copy'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-files-o + button.btn.btn-link.action-btn( + tooltip=translate('download'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-cloud-download + button.btn.btn-link.action-btn( + tooltip=translate('archive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-inbox \ No newline at end of file diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index df3c2bf681..6e1732ad63 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -131,7 +131,7 @@ ) li.container-fluid .row - .col-xs-6 + .col-xs-6.col-sm-4.col-md-6 input.select-all( select-all, type="checkbox" @@ -142,9 +142,11 @@ .col-xs-2 span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")} i.tablesort.fa(ng-class="getSortIconClass('accessLevel')") - .col-xs-4 + .col-xs-4.col-sm-3.col-md-2 span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")} i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')") + .hidden-xs.col-sm-3.col-md-2 + span.header #{translate("actions")} li.project_entry.container-fluid( ng-repeat="project in visibleProjects | orderBy:predicate:reverse", ng-controller="ProjectListItemController" diff --git a/services/web/app/views/project/list/v1-item.pug b/services/web/app/views/project/list/v1-item.pug index b4a3ccb99d..5a8e37bca0 100644 --- a/services/web/app/views/project/list/v1-item.pug +++ b/services/web/app/views/project/list/v1-item.pug @@ -1,4 +1,4 @@ -.col-xs-6 +.col-xs-6.col-sm-4.col-md-6 .select-item span.v1-badge( aria-label=translate("v1_badge") @@ -21,5 +21,5 @@ .col-xs-2 span.owner {{ownerName()}} -.col-xs-4 +.col-xs-4.col-sm-3.col-md-2 span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} \ No newline at end of file From 2d1bcda9ff7a70e0ff628d843d6042fb6604b936 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 16 May 2018 16:55:23 +0100 Subject: [PATCH 088/135] Style action buttons, and hide on smaller screens --- services/web/public/stylesheets/app/project-list.less | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 982086a262..0843fc6784 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -369,6 +369,16 @@ ul.project-list { .v1-badge { margin-left: -4px; } + + .action-btn-row { + padding-right: 20px; + } + + .action-btn { + padding: 0 0.3em; + margin-left: 0.2em; + float: right; + } } i.tablesort { padding-left: 8px; From 83c62c8ab14c41229b394c7f6a783eb8c0a873ed Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 16 May 2018 18:05:33 +0100 Subject: [PATCH 089/135] Only show action buttons on v2 --- services/web/app/views/project/list/item.pug | 48 +++++++++++-------- .../app/views/project/list/project-list.pug | 12 +++-- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index b2fc9390de..ccbf73ac80 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -1,4 +1,7 @@ -.col-xs-6.col-sm-4.col-md-6 +- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6" +- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + +div(class=titleClasses) input.select-item( select-individual, type="checkbox", @@ -37,27 +40,30 @@ tooltip-placement="right" tooltip-append-to-body="true" ) -.col-xs-4.col-sm-3.col-md-2 + +div(class=lastUpdatedClasses) if settings.overleaf span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} else span.last-modified {{project.lastUpdated | formatDate}} -.hidden-xs.col-sm-3.col-md-2 - button.btn.btn-link.action-btn( - tooltip=translate('copy'), - tooltip-placement="top", - tooltip-append-to-body="true", - ) - i.icon.fa.fa-files-o - button.btn.btn-link.action-btn( - tooltip=translate('download'), - tooltip-placement="top", - tooltip-append-to-body="true", - ) - i.icon.fa.fa-cloud-download - button.btn.btn-link.action-btn( - tooltip=translate('archive'), - tooltip-placement="top", - tooltip-append-to-body="true", - ) - i.icon.fa.fa-inbox \ No newline at end of file + +if settings.overleaf + .hidden-xs.col-sm-3.col-md-2 + button.btn.btn-link.action-btn( + tooltip=translate('copy'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-files-o + button.btn.btn-link.action-btn( + tooltip=translate('download'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-cloud-download + button.btn.btn-link.action-btn( + tooltip=translate('archive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ) + i.icon.fa.fa-inbox \ No newline at end of file diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 6e1732ad63..5ce92d3d5e 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -131,7 +131,10 @@ ) li.container-fluid .row - .col-xs-6.col-sm-4.col-md-6 + - var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6" + - var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + + div(class=titleClasses) input.select-all( select-all, type="checkbox" @@ -142,11 +145,12 @@ .col-xs-2 span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")} i.tablesort.fa(ng-class="getSortIconClass('accessLevel')") - .col-xs-4.col-sm-3.col-md-2 + div(class=lastUpdatedClasses) span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")} i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')") - .hidden-xs.col-sm-3.col-md-2 - span.header #{translate("actions")} + if settings.overleaf + .hidden-xs.col-sm-3.col-md-2 + span.header #{translate("actions")} li.project_entry.container-fluid( ng-repeat="project in visibleProjects | orderBy:predicate:reverse", ng-controller="ProjectListItemController" From a2dff4bfbbfa69e044953f6acdfe6a7dc90e4146 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 17 May 2018 11:12:52 +0100 Subject: [PATCH 090/135] Right align actions header --- services/web/app/views/project/list/item.pug | 2 +- services/web/app/views/project/list/project-list.pug | 2 +- services/web/public/stylesheets/app/project-list.less | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index ccbf73ac80..3326168105 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -48,7 +48,7 @@ div(class=lastUpdatedClasses) span.last-modified {{project.lastUpdated | formatDate}} if settings.overleaf - .hidden-xs.col-sm-3.col-md-2 + .hidden-xs.col-sm-3.col-md-2.action-btn-row button.btn.btn-link.action-btn( tooltip=translate('copy'), tooltip-placement="top", diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 5ce92d3d5e..cfea4aa6c6 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -149,7 +149,7 @@ span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")} i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')") if settings.overleaf - .hidden-xs.col-sm-3.col-md-2 + .hidden-xs.col-sm-3.col-md-2.action-btn-row-header span.header #{translate("actions")} li.project_entry.container-fluid( ng-repeat="project in visibleProjects | orderBy:predicate:reverse", diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 0843fc6784..569035ad72 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -370,14 +370,14 @@ ul.project-list { margin-left: -4px; } - .action-btn-row { + .action-btn-row-header, .action-btn-row { padding-right: 20px; + text-align: right; } .action-btn { padding: 0 0.3em; margin-left: 0.2em; - float: right; } } i.tablesort { From ffc06f2a3b4365a194bf9bfafdab96967947fb83 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 17 May 2018 14:28:34 +0100 Subject: [PATCH 091/135] Archive project action button --- services/web/app/views/project/list/item.pug | 1 + .../coffee/main/project-list/project-list.coffee | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index 3326168105..50a613980f 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -65,5 +65,6 @@ if settings.overleaf tooltip=translate('archive'), tooltip-placement="top", tooltip-append-to-body="true", + ng-click="archive($event)" ) i.icon.fa.fa-inbox \ No newline at end of file diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 36520d2cc7..0a01eb52d3 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -350,14 +350,15 @@ define [ $scope.archiveOrLeaveSelectedProjects() $scope.archiveOrLeaveSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.archiveOrLeaveProjects($scope.getSelectedProjects()) + $scope.archiveOrLeaveProjects = (projects) -> + projectIds = projects.map (p) -> p.id # Remove project from any tags for tag in $scope.tags - $scope._removeProjectIdsFromTagArray(tag, selected_project_ids) + $scope._removeProjectIdsFromTagArray(tag, projectIds) - for project in selected_projects + for project in projects project.tags = [] if project.accessLevel == "owner" project.archived = true @@ -490,3 +491,7 @@ define [ $scope.$watch "project.selected", (value) -> if value? $scope.updateSelectedProjects() + + $scope.archive = (e) -> + e.stopPropagation() + $scope.archiveOrLeaveProjects([$scope.project]) From 2354f4156bf0cddcf07a62acf812c1a43fdb2ef1 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 17 May 2018 14:28:34 +0100 Subject: [PATCH 092/135] Download project action button --- services/web/app/views/project/list/item.pug | 1 + .../main/project-list/project-list.coffee | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index 50a613980f..2f008101f9 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -59,6 +59,7 @@ if settings.overleaf tooltip=translate('download'), tooltip-placement="top", tooltip-append-to-body="true", + ng-click="download($event)" ) i.icon.fa.fa-cloud-download button.btn.btn-link.action-btn( diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 0a01eb52d3..f458ebd15c 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -438,13 +438,14 @@ define [ ) $scope.downloadSelectedProjects = () -> - selected_project_ids = $scope.getSelectedProjectIds() - event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' - if selected_project_ids.length > 1 - path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}" - else - path = "/project/#{selected_project_ids[0]}/download/zip" + $scope.downloadProjectsById($scope.getSelectedProjectIds()) + $scope.downloadProjectsById = (projectIds) -> + event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' + if projectIds.length > 1 + path = "/project/download/zip?project_ids=#{projectIds.join(',')}" + else + path = "/project/#{projectIds[0]}/download/zip" window.location = path $scope.openV1ImportModal = (project) -> @@ -492,6 +493,10 @@ define [ if value? $scope.updateSelectedProjects() + $scope.download = (e) -> + e.stopPropagation() + $scope.downloadProjectsById([$scope.project.id]) + $scope.archive = (e) -> e.stopPropagation() $scope.archiveOrLeaveProjects([$scope.project]) From 7dffc568049e95665293711e0dde43b0fb2d8e15 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 17 May 2018 14:28:34 +0100 Subject: [PATCH 093/135] Clone project action button --- services/web/app/views/project/list/item.pug | 1 + .../web/public/coffee/main/project-list/project-list.coffee | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index 2f008101f9..a5362ebfd7 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -53,6 +53,7 @@ if settings.overleaf tooltip=translate('copy'), tooltip-placement="top", tooltip-append-to-body="true", + ng-click="clone($event)" ) i.icon.fa.fa-files-o button.btn.btn-link.action-btn( diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index f458ebd15c..72074a19b9 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -493,6 +493,10 @@ define [ if value? $scope.updateSelectedProjects() + $scope.clone = (e) -> + e.stopPropagation() + $scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)") + $scope.download = (e) -> e.stopPropagation() $scope.downloadProjectsById([$scope.project.id]) From 5ec238cae88483325d4b93c960b8aadd71dac6e2 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 17 May 2018 15:04:50 +0100 Subject: [PATCH 094/135] Switch archive button with restore button for archived projects --- services/web/app/views/project/list/item.pug | 11 ++++++++++- .../coffee/main/project-list/project-list.coffee | 13 +++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index a5362ebfd7..bc37683489 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -64,9 +64,18 @@ if settings.overleaf ) i.icon.fa.fa-cloud-download button.btn.btn-link.action-btn( + ng-if="!project.archived" tooltip=translate('archive'), tooltip-placement="top", tooltip-append-to-body="true", ng-click="archive($event)" ) - i.icon.fa.fa-inbox \ No newline at end of file + i.icon.fa.fa-inbox + button.btn.btn-link.action-btn( + ng-if="project.archived" + tooltip=translate('unarchive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="restore($event)" + ) + i.icon.fa.fa-reply \ No newline at end of file diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 72074a19b9..c2b251bd65 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -415,13 +415,14 @@ define [ $scope.updateVisibleProjects() $scope.restoreSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.restoreProjects($scope.getSelectedProjects()) - for project in selected_projects + $scope.restoreProjects = (projects) -> + projectIds = projects.map (p) -> p.id + for project in projects project.archived = false - for project_id in selected_project_ids + for projectId in projectIds queuedHttp { method: "POST" url: "/project/#{project_id}/restore" @@ -504,3 +505,7 @@ define [ $scope.archive = (e) -> e.stopPropagation() $scope.archiveOrLeaveProjects([$scope.project]) + + $scope.restore = (e) -> + e.stopPropagation() + $scope.restoreProjects([$scope.project]) From 7f86ddc72c55ce61945066ef7bc5e537fa15f91c Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Thu, 31 May 2018 11:12:31 +0100 Subject: [PATCH 095/135] extract v1 templates code to web --- .../Templates/TemplatesController.coffee | 80 +++++++++++++++++++ .../Templates/TemplatesMiddlewear.coffee | 9 +++ .../Features/Templates/TemplatesRouter.coffee | 10 +++ services/web/app/coffee/router.coffee | 3 +- .../project/editor/new_from_template.pug | 26 ++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 services/web/app/coffee/Features/Templates/TemplatesController.coffee create mode 100644 services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee create mode 100644 services/web/app/coffee/Features/Templates/TemplatesRouter.coffee create mode 100644 services/web/app/views/project/editor/new_from_template.pug diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee new file mode 100644 index 0000000000..0d687a7f06 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -0,0 +1,80 @@ +path = require('path') +Project = require('../../../js/models/Project').Project +ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') +ProjectOptionsHandler = require("../../../js/Features/Project/ProjectOptionsHandler") +AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') +settings = require('settings-sharelatex') +fs = require('fs') +request = require('request') +uuid = require('uuid') +logger = require('logger-sharelatex') +async = require("async") + + +module.exports = TemplatesController = + + getV1Template: (req, res)-> + templateVersionId = req.params.Template_version_id + templateId = req.query.id + if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId) + logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version" + return res.sendStatus 400 + data = {} + data.templateVersionId = templateVersionId + data.templateId = templateId + data.name = req.query.templateName + data.compiler = req.query.latexEngine + res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data + + createProjectFromV1Template: (req, res)-> + currentUserId = AuthenticationController.getLoggedInUserId(req) + zipUrl = "#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}" + zipReq = request(zipUrl, { + 'auth': { + 'user': settings.apis.v1.user, + 'pass': settings.apis.v1.pass + } + }) + + TemplatesController.createFromZip( + zipReq, + { + templateName: req.body.templateName, + currentUserId: currentUserId, + compiler: req.body.compiler + docId: req.body.docId + templateId: req.body.templateId + templateVersionId: req.body.templateVersionId + }, + req, + res + ) + + createFromZip: (zipReq, options, req, res)-> + dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}" + writeStream = fs.createWriteStream(dumpPath) + + zipReq.on "error", (error) -> + logger.error err: error, "error getting zip from template API" + zipReq.pipe(writeStream) + writeStream.on 'close', -> + ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)-> + if err? + logger.err err:err, zipReq:zipReq, "problem building project from zip" + return res.sendStatus 500 + setCompiler project._id, options.compiler, -> + fs.unlink dumpPath, -> + delete req.session.templateData + conditions = {_id:project._id} + update = { + fromV1TemplateId:options.templateId, + fromV1TemplateVersionId:options.templateVersionId + } + Project.update conditions, update, {}, (err)-> + res.redirect "/project/#{project._id}" + +setCompiler = (project_id, compiler, callback)-> + if compiler? + ProjectOptionsHandler.setCompiler project_id, compiler, callback + else + callback() diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee new file mode 100644 index 0000000000..8baa0ca605 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee @@ -0,0 +1,9 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee new file mode 100644 index 0000000000..3061789591 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee @@ -0,0 +1,10 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +TemplatesController = require("./TemplatesController") +TemplatesMiddlewear = require('./TemplatesMiddlewear') + +module.exports = + apply: (app)-> + + app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template + + app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index e6b2692f7c..2e766ac178 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController') TokenAccessController = require('./Features/TokenAccess/TokenAccessController') Features = require('./infrastructure/Features') LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter' +TemplatesRouter = require './Features/Templates/TemplatesRouter' logger = require("logger-sharelatex") _ = require("underscore") @@ -80,10 +81,10 @@ module.exports = class Router ContactRouter.apply(webRouter, privateApiRouter) AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if Settings.enableSubscriptions webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug new file mode 100644 index 0000000000..6dc27a4241 --- /dev/null +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -0,0 +1,26 @@ +extends ../../layout + +block content + script. + $(document).ready(function(){ + $('#create_form').submit(); + }); + + .editor.full-size + .loading-screen() + .loading-screen-brand-container + .loading-screen-brand( + style="height: 20%;" + ) + + h3.loading-screen-label() #{translate("Opening template")} + span.loading-screen-ellip . + span.loading-screen-ellip . + span.loading-screen-ellip . + + form(id='create_form' method='POST' action='/project/new/template/') + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden" name="templateId" value=templateId) + input(type="hidden" name="templateVersionId" value=templateVersionId) + input(type="hidden" name="templateName" value=name) + input(type="hidden" name="compiler" value=compiler) From d47e84536716ead511bef4cbe62f4f888625fb36 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Thu, 31 May 2018 12:15:42 +0100 Subject: [PATCH 096/135] add v1 template tests --- .../Templates/TemplatesController.coffee | 2 +- .../Features/Templates/TemplatesMiddlewear | 8 ++ .../Templates/TemplatesControllerTests.coffee | 76 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 services/web/app/coffee/Features/Templates/TemplatesMiddlewear create mode 100644 services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index 0d687a7f06..fce6c9502c 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -1,7 +1,7 @@ path = require('path') Project = require('../../../js/models/Project').Project ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') -ProjectOptionsHandler = require("../../../js/Features/Project/ProjectOptionsHandler") +ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler') AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') settings = require('settings-sharelatex') fs = require('fs') diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear new file mode 100644 index 0000000000..300721c889 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear @@ -0,0 +1,8 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee new file mode 100644 index 0000000000..5cf52eca39 --- /dev/null +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -0,0 +1,76 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = '../../../../app/js/Features/Templates/TemplatesController' + + +describe 'TemplatesController', -> + + project_id = "213432" + + beforeEach -> + @request = sinon.stub() + @request.returns { + pipe:-> + on:-> + } + @fs = { + unlink : sinon.stub() + createWriteStream : sinon.stub().returns(on:(_, cb)->cb()) + } + @ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})} + @dumpFolder = "dump/path" + @ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)} + @uuid = "1234" + @ProjectDetailsHandler = + getProjectDescription:sinon.stub() + @Project = + update: sinon.stub().callsArgWith(3, null) + @controller = SandboxedModule.require modulePath, requires: + '../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager + '../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler + '../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()} + './TemplatesPublisher':@TemplatesPublisher + "logger-sharelatex": + log:-> + err:-> + "settings-sharelatex": + path: + dumpFolder:@dumpFolder + siteUrl: @siteUrl = "http://localhost:3000" + apis: + v1: + url: @v1Url="http://overleaf.com" + user: "sharelatex" + pass: "password" + overleaf: + host: @v1Url + "uuid":v4:=>@uuid + "request": @request + "fs":@fs + "../../../../app/js/models/Project": {Project: @Project} + @zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex" + @templateName = "project name here" + @user_id = "1234" + @req = + session: + user: _id:@user_id + templateData: + zipUrl: @zipUrl + templateName: @templateName + @redirect = {} + @AuthenticationController.getLoggedInUserId.returns(@user_id) + + describe 'v1Templates', -> + + it "should fetch zip from v1 based on template id", (done)-> + @templateVersionId = 15 + @req.body = {templateVersionId: @templateVersionId} + + redirect = => + @request.calledWith("#{@v1Url}/api/v1/sharelatex/templates/#{@templateVersionId}").should.equal true + done() + res = redirect:redirect + @controller.createProjectFromV1Template @req, res From f5367985c3153d97214d9873e914451b0dab3bd2 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 31 May 2018 13:44:37 +0100 Subject: [PATCH 097/135] Don't allow read-only users to restore --- services/web/app/coffee/router.coffee | 2 +- services/web/app/views/project/editor/history/diffPanelV1.pug | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index e6b2692f7c..51c51cade2 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -201,7 +201,7 @@ module.exports = class Router webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails - webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2 privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory diff --git a/services/web/app/views/project/editor/history/diffPanelV1.pug b/services/web/app/views/project/editor/history/diffPanelV1.pug index 1720f48b59..30588adf46 100644 --- a/services/web/app/views/project/editor/history/diffPanelV1.pug +++ b/services/web/app/views/project/editor/history/diffPanelV1.pug @@ -13,7 +13,7 @@ }" ) | in {{history.diff.pathname}} - .toolbar-right + .toolbar-right(ng-if="permissions.write") a.btn.btn-danger.btn-sm( href, ng-click="openRestoreDiffModal()" From c4f3a12ce5c75db00539465e75b25b320f9e6f85 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 31 May 2018 14:45:43 +0100 Subject: [PATCH 098/135] add missing locking to copyFileFromExistingProject --- .../Project/ProjectEntityUpdateHandler.coffee | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee index 27ead91841..22034200f5 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee @@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) -> methodWithLock module.exports = ProjectEntityUpdateHandler = self = - # this doesn't need any locking because it's only called by ProjectDuplicator - copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> - project_id = project._id - projectHistoryId = project.overleaf?.history?.id - logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" - return callback(err) if err? - ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=> - if !origonalFileRef? - logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" - return callback() - # convert any invalid characters in original file to '_' - fileRef = new File name : SafePath.clean(origonalFileRef.name) - FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> - if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" - return callback(err) - ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=> - if err? - logger.err { err, project_id, folder_id }, "error putting element as part of copy" - return callback(err) - TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + copyFileFromExistingProjectWithProject: wrapWithLock + beforeLock: (next) -> + (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" + ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) -> + if !origonalFileRef? + logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" + return callback() + # convert any invalid characters in original file to '_' + fileRef = new File name : SafePath.clean(origonalFileRef.name) + FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" - newFiles = [ - file: fileRef - path: result?.path?.fileSystem - url: fileStoreUrl - ] - DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) -> - return callback(error) if error? - callback null, fileRef, folder_id + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" + return callback(err) + next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback) + withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + projectHistoryId = project.overleaf?.history?.id + ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) -> + if err? + logger.err { err, project_id, folder_id }, "error putting element as part of copy" + return callback(err) + TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + if err? + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" + newFiles = [ + file: fileRef + path: result?.path?.fileSystem + url: fileStoreUrl + ] + DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) -> + return callback(error) if error? + callback null, fileRef, folder_id updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)-> ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> From 063187b5fc5f0e8a6171f5f2780beb53c46d97e1 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Thu, 31 May 2018 17:03:41 +0100 Subject: [PATCH 099/135] add function to check for existance of folders --- .../web/public/coffee/ide/file-tree/FileTreeManager.coffee | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index d7a428ec80..8d717e09b8 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -335,6 +335,11 @@ define [ return null + projectContainsFolder: () -> + for entity in @$scope.rootFolder.children + return true if entity.type == 'folder' + return false + existsInThisFolder: (folder, name) -> for entity in folder?.children or [] return true if entity.name is name From cfbb5c8f24be4769f6804233d37946ee2e7eb7ff Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Jun 2018 14:55:07 +0100 Subject: [PATCH 100/135] Remove some dead code --- .../web/app/coffee/Features/Subscription/FeaturesUpdater.coffee | 2 -- .../coffee/Features/Subscription/V1SubscriptionManager.coffee | 1 - 2 files changed, 3 deletions(-) diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index dc7571ea50..5627072c93 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -90,5 +90,3 @@ module.exports = FeaturesUpdater = return {} else return plan.features - - _notifyV1: (user_id, callback = (error) ->) -> diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee index f297984dd3..e920b94f6c 100644 --- a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee +++ b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee @@ -41,7 +41,6 @@ module.exports = V1SubscriptionManager = if !v1Id? logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user" return callback(null, null) - options.url = options.url request { baseUrl: settings.apis.v1.url url: options.url(v1Id) From 7898a1decad4be7cf1f2e44d4e0124a464f17993 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 4 Jun 2018 10:45:23 +0100 Subject: [PATCH 101/135] Fix missed snake_case to camelCase, causing bug where projects couldn't be restored --- .../web/public/coffee/main/project-list/project-list.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index c2b251bd65..6696243905 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -425,7 +425,7 @@ define [ for projectId in projectIds queuedHttp { method: "POST" - url: "/project/#{project_id}/restore" + url: "/project/#{projectId}/restore" headers: "X-CSRF-Token": window.csrfToken } From 4c5f186ca24bc213579fcfbc1879c6a5a994262b Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Mon, 4 Jun 2018 11:05:47 +0100 Subject: [PATCH 102/135] take custom first and last names from export UI --- .../Features/Exports/ExportsController.coffee | 12 +++++++++++- .../coffee/Features/Exports/ExportsHandler.coffee | 13 ++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index b60f58ba20..c2a8d0d617 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -7,7 +7,17 @@ module.exports = exportProject: (req, res) -> {project_id, brand_variation_id} = req.params user_id = AuthenticationController.getLoggedInUserId(req) - ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) -> + export_params = { + project_id: project_id, + brand_variation_id: brand_variation_id, + user_id: user_id + } + + if req.body && req.body.firstName && req.body.firstName + export_params.first_name = req.body.firstName + export_params.last_name = req.body.lastName + + ExportsHandler.exportProject export_params, (err, export_data) -> return next(err) if err? logger.log user_id:user_id diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee index 727b01a575..38357c129d 100644 --- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee @@ -10,8 +10,8 @@ settings = require 'settings-sharelatex' module.exports = ExportsHandler = self = - exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) -> - self._buildExport project_id, user_id, brand_variation_id, (err, export_data) -> + exportProject: (export_params, callback=(error, export_data) ->) -> + self._buildExport export_params, (err, export_data) -> return callback(err) if err? self._requestExport export_data, (err, export_v1_id) -> return callback(err) if err? @@ -19,7 +19,10 @@ module.exports = ExportsHandler = self = # TODO: possibly store the export data in Mongo callback null, export_data - _buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) -> + _buildExport: (export_params, callback=(err, export_data) ->) -> + project_id = export_params.project_id + user_id = export_params.user_id + brand_variation_id = export_params.brand_variation_id jobs = project: (cb) -> ProjectGetter.getProject project_id, cb @@ -43,6 +46,10 @@ module.exports = ExportsHandler = self = logger.err err:err, project_id: project_id return callback(err) + if export_params.first_name && export_params.last_name + user.first_name = export_params.first_name + user.last_name = export_params.last_name + export_data = project: id: project_id From b02eea1e7e86dea533ebc9be89565101625eb8b9 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Mon, 4 Jun 2018 11:07:47 +0100 Subject: [PATCH 103/135] update tests for exports name options --- .../coffee/Exports/ExportsHandlerTests.coffee | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee index 6333db8270..8589de25c4 100644 --- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee @@ -27,6 +27,11 @@ describe 'ExportsHandler', -> @project_history_id = 987 @user_id = "user-id-456" @brand_variation_id = 789 + @export_params = { + project_id: @project_id, + brand_variation_id: @brand_variation_id, + user_id: @user_id + } @callback = sinon.stub() describe 'exportProject', -> @@ -35,13 +40,13 @@ describe 'ExportsHandler', -> @response_body = {iAmAResponseBody: true} @ExportsHandler._buildExport = sinon.stub().yields(null, @export_data) @ExportsHandler._requestExport = sinon.stub().yields(null, @response_body) - @ExportsHandler.exportProject @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler.exportProject @export_params, (error, export_data) => @callback(error, export_data) done() it "should build the export", -> @ExportsHandler._buildExport - .calledWith(@project_id, @user_id, @brand_variation_id) + .calledWith(@export_params) .should.equal true it "should request the export", -> @@ -76,7 +81,7 @@ describe 'ExportsHandler', -> describe "when all goes well", -> beforeEach (done) -> - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -104,10 +109,11 @@ describe 'ExportsHandler', -> @callback.calledWith(null, expected_export_data) .should.equal true + describe "when project is not found", -> beforeEach (done) -> @ProjectGetter.getProject = sinon.stub().yields(new Error("project not found")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -118,7 +124,7 @@ describe 'ExportsHandler', -> describe "when project has no root doc", -> beforeEach (done) -> @ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, null]) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -129,7 +135,7 @@ describe 'ExportsHandler', -> describe "when user is not found", -> beforeEach (done) -> @UserGetter.getUser = sinon.stub().yields(new Error("user not found")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -140,7 +146,7 @@ describe 'ExportsHandler', -> describe "when project history request fails", -> beforeEach (done) -> @ExportsHandler._requestVersion = sinon.stub().yields(new Error("project history call failed")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() From 54ce19650035b06daf0f938010ef3fd7bc02d87b Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Mon, 4 Jun 2018 11:11:40 +0100 Subject: [PATCH 104/135] test custom first and last name for exports --- .../Features/Exports/ExportsController.coffee | 4 +-- .../coffee/Exports/ExportsHandlerTests.coffee | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index c2a8d0d617..393ca95fdb 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -14,8 +14,8 @@ module.exports = } if req.body && req.body.firstName && req.body.firstName - export_params.first_name = req.body.firstName - export_params.last_name = req.body.lastName + export_params.first_name = req.body.firstName.trim() + export_params.last_name = req.body.lastName.trim() ExportsHandler.exportProject export_params, (err, export_data) -> return next(err) if err? diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee index 8589de25c4..f10f4631c1 100644 --- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee @@ -109,6 +109,35 @@ describe 'ExportsHandler', -> @callback.calledWith(null, expected_export_data) .should.equal true + describe "when we send replacement user first and last name", -> + beforeEach (done) -> + @custom_first_name = "FIRST" + @custom_last_name = "LAST" + @export_params.first_name = @custom_first_name + @export_params.last_name = @custom_last_name + @ExportsHandler._buildExport @export_params, (error, export_data) => + @callback(error, export_data) + done() + + it "should send the data from the user input", -> + expected_export_data = + project: + id: @project_id + rootDocPath: @rootDocPath + historyId: @project_history_id + historyVersion: @historyVersion + user: + id: @user_id + firstName: @custom_first_name + lastName: @custom_last_name + email: @user.email + orcidId: null + destination: + brandVariationId: @brand_variation_id + options: + callbackUrl: null + @callback.calledWith(null, expected_export_data) + .should.equal true describe "when project is not found", -> beforeEach (done) -> From 10cf5825a59ff5da7de73ed1fc75c5e2b0b2178d Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 12 Apr 2018 16:11:52 -0500 Subject: [PATCH 105/135] Add plans variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also, fix for multiple quotes. Without a closing quote subsequent quotes are considered nested, and will use a ‘ instead of “ Also, move repeated elements to partials --- .../SubscriptionController.coffee | 3 + .../Features/Subscription/planFeatures.coffee | 133 +++++++++ .../_plans_page_details_less.pug | 118 ++++++++ .../_plans_page_details_more.pug | 160 +++++++++++ .../subscriptions/_plans_page_mixins.pug | 162 +++++++++++ .../subscriptions/_plans_page_tables.pug | 107 +++++++ services/web/app/views/subscriptions/new.pug | 5 +- .../web/app/views/subscriptions/plans.pug | 251 +---------------- services/web/public/coffee/main/event.coffee | 2 +- .../coffee/main/new-subscription.coffee | 4 + services/web/public/coffee/main/plans.coffee | 69 +++-- .../web/public/img/advocates/erdogmus.jpg | Bin 0 -> 35341 bytes .../web/public/img/advocates/henderson.jpg | Bin 0 -> 26640 bytes .../web/public/stylesheets/app/plans.less | 265 +++++++++++++++++- .../public/stylesheets/core/scaffolding.less | 3 + .../web/public/stylesheets/core/type.less | 14 +- 16 files changed, 1020 insertions(+), 276 deletions(-) create mode 100644 services/web/app/coffee/Features/Subscription/planFeatures.coffee create mode 100644 services/web/app/views/subscriptions/_plans_page_details_less.pug create mode 100644 services/web/app/views/subscriptions/_plans_page_details_more.pug create mode 100644 services/web/app/views/subscriptions/_plans_page_mixins.pug create mode 100644 services/web/app/views/subscriptions/_plans_page_tables.pug create mode 100644 services/web/public/img/advocates/erdogmus.jpg create mode 100644 services/web/public/img/advocates/henderson.jpg diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index 32d2abf594..03e87125ac 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -10,6 +10,7 @@ GeoIpLookup = require("../../infrastructure/GeoIpLookup") SubscriptionDomainHandler = require("./SubscriptionDomainHandler") UserGetter = require "../User/UserGetter" FeaturesUpdater = require './FeaturesUpdater' +planFeatures = require './planFeatures' module.exports = SubscriptionController = @@ -20,6 +21,7 @@ module.exports = SubscriptionController = viewName = "#{viewName}_#{req.query.v}" logger.log viewName:viewName, "showing plans page" currentUser = null + GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)-> return next(err) if err? render = () -> @@ -29,6 +31,7 @@ module.exports = SubscriptionController = gaExperiments: Settings.gaExperiments.plansPage recomendedCurrency:recomendedCurrency shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27'))) + planFeatures: planFeatures user_id = AuthenticationController.getLoggedInUserId(req) if user_id? UserGetter.getUser user_id, {signUpDate: 1}, (err, user) -> diff --git a/services/web/app/coffee/Features/Subscription/planFeatures.coffee b/services/web/app/coffee/Features/Subscription/planFeatures.coffee new file mode 100644 index 0000000000..8c9c276955 --- /dev/null +++ b/services/web/app/coffee/Features/Subscription/planFeatures.coffee @@ -0,0 +1,133 @@ +module.exports = + [ + { + feature: 'number_collab' + value: 'str' + plans: { + free: '1' + coll: '10' + prof: 'unlimited' + } + student: '6' + } + { + feature: 'unlimited_private' + value: 'bool' + info: 'unlimited_private_info' + plans: { + free: true + coll: true + prof: true + }, + student: true + } + { + feature: 'realtime_collab' + value: 'bool' + info: 'realtime_collab_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'hundreds_templates' + value: 'bool' + info: 'hundreds_templates_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'powerful_latex_editor' + value: 'bool' + info: 'latex_editor_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'realtime_track_changes' + value: 'bool' + info: 'realtime_track_changes_info' + plans: { + free: false + coll: true + prof: true + }, + student: true + } + { + feature: 'reference_search' + value: 'bool' + info: 'reference_search_info' + plans: { + free: false + coll: true + prof: true + }, + student: true + }, + { + feature: 'reference_sync' + info: 'reference_sync_info' + value: 'bool' + plans: { + free: false + coll: true + prof: true + }, + student: true + } + { + feature: 'full_doc_history' + value: 'bool' + info: 'full_doc_history_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + } + { + feature: 'dropbox_integration_lowercase' + value: 'bool' + info: 'dropbox_integration_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + { + feature: 'github_integration_lowercase' + value: 'bool' + info: 'github_integration_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + { + feature: 'priority_support', + value: 'bool', + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + ] \ No newline at end of file diff --git a/services/web/app/views/subscriptions/_plans_page_details_less.pug b/services/web/app/views/subscriptions/_plans_page_details_less.pug new file mode 100644 index 0000000000..2aa572e4e7 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_details_less.pug @@ -0,0 +1,118 @@ +.row + .col-md-12 + .page-header.centered.plans-header.text-centered + h1 #{translate("start_x_day_trial", {len:'{{trial_len}}'})} +.row + .col-md-8.col-md-offset-2 + p.text-centered #{translate("sl_benefits_plans")} + +.row.top-switch + .col-md-6.col-md-offset-3 + +plan_switch('card') + .col-md-2.text-right + +currency_dropdown + +div(ng-show="showPlans") + .row + .col-md-10.col-md-offset-1 + .row + .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + .circle #{translate("free")} + +features_free + .col-md-4 + .card.card-highlighted + .card-header + h2 #{translate("collaborator")} + .circle + +price_collaborator + +features_collaborator + .col-md-4 + .card.card-last + .card-header + h2 #{translate("professional")} + .circle + +price_professional + +features_professional + + .card-group.text-centered(ng-if="ui.view == 'student'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + .circle #{translate("free")} + +features_free + + .col-md-4 + .card.card-highlighted + +card_student_monthly + + .col-md-4 + .card.card-last + +card_student_annual + +.row.row-spaced + p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})} + +.row + .col-md-8.col-md-offset-2 + .alert.alert-info.text-centered + | #{translate("interested_in_group_licence")} + br + a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")} + + script(type="text/ng-template", id="groupPlanModalTemplate") + .modal-header + h3 #{translate("group_plan_enquiry")} + .modal-body + form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()") + span(ng-show="sent == false && error == false") + .form-group + label#title9(for='Field9') + | Name + input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='') + label#title11.desc(for='Field11') + | Email + .form-group + input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + label#title12.desc(for='Field12') + | University / Company + .form-group + input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') + label#title13.desc(for='Field13') + | Position + .form-group + input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') + .form-group + input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';") + .form-group.text-center + input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') + span(ng-show="sent == true && error == false") + p Request Sent, Thank you. + span(ng-show="error") + p Error sending request. + +.row + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate("enjoy_these_features")} + .col-md-4 + .card.features.text-centered + i.fa.fa-file-text-o.fa-5x + h4 #{translate("unlimited_projects")} + p #{translate("create_unlimited_projects")} + .col-md-4 + .card.features.text-centered + i.fa.fa-clock-o.fa-5x + h4 #{translate("full_doc_history")} + p #{translate("never_loose_work")} + .col-md-4 + .card.features.text-centered + i.fa.fa-dropbox.fa-5x + |     + i.fa.fa-github.fa-5x + h4 #{translate("sync_to_dropbox_and_github")} + p #{translate("access_projects_anywhere")} \ No newline at end of file diff --git a/services/web/app/views/subscriptions/_plans_page_details_more.pug b/services/web/app/views/subscriptions/_plans_page_details_more.pug new file mode 100644 index 0000000000..17c683201e --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_details_more.pug @@ -0,0 +1,160 @@ +.row + .col-md-12 + .page-header.centered.plans-header.text-centered + h1.text-capitalize #{translate('instant_access')} +.row + .col-md-8.col-md-offset-2 + p.text-centered #{translate("sl_benefits_plans")} + +.row.top-switch + .col-md-6.col-md-offset-3 + +plan_switch('card') + .col-md-2.text-right + +currency_dropdown + +div(ng-show="showPlans") + .row + .col-md-10.col-md-offset-1 + .row + .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + h5.tagline #{translate("tagline_personal")} + .circle #{translate("free")} + +features_free + .col-md-4 + .card.card-highlighted + .best-value + strong #{translate('best_value')} + .card-header + h2 #{translate("collaborator")} + h5.tagline #{translate("tagline_collaborator")} + .circle + +price_collaborator + +features_collaborator + .col-md-4 + .card.card-last + .card-header + h2 #{translate("professional")} + h5.tagline #{translate("tagline_professional")} + .circle + +price_professional + +features_professional + + .card-group.text-centered(ng-if="ui.view == 'student'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + h5.tagline #{translate("tagline_personal")} + .circle #{translate("free")} + +features_free + + .col-md-4 + .card.card-highlighted + +card_student_annual + + .col-md-4 + .card.card-last + +card_student_monthly + +.row.row-spaced-large.text-centered + i.fa.fa-cc-mastercard.fa-2x   + i.fa.fa-cc-visa.fa-2x   + i.fa.fa-cc-amex.fa-2x   + i.fa.fa-cc-paypal.fa-2x   + div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'{{trial_len}}'})} + +.row.row-spaced-large + .col-md-8.col-md-offset-2 + .card.text-centered + .card-header + h2 #{translate('looking_multiple_licenses')} + span #{translate('reduce_costs_group_licenses')} + br + br + a.btn.btn-info(href="/i/university/groups") #{translate('find_out_more')} + +div + .row.row-spaced-large + .col-sm-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('compare_plan_features')} + .row + .col-md-6.col-md-offset-3 + +plan_switch('table') + .col-md-3.text-right + +currency_dropdown + .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true") + .col-sm-12(ng-if="ui.view != 'student'") + +table_premium + .col-sm-12(ng-if="ui.view == 'student'") + +table_student + + .row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('in_good_company')} + .row + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/erdogmus.jpg') alt="Professor Erdogmus") + .col-md-9 + blockquote + p The ability to track changes and the real-time collaborative nature is what sets ShareLaTeX apart. + footer Professor Erdogmus, Northeastern University + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/henderson.jpg') alt="Rob Henderson") + .col-md-9 + blockquote + p ShareLaTeX has proven to be a powerful and robust collaboration tool that is widely used in our School. + footer Rob Henderson, School Of Informatics And Computing - Indiana University + + .faq + .row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 FAQ + .row + .col-md-6 + h3 #{translate("faq_how_free_trial_works_question")} + p #{translate('faq_how_free_trial_works_answer', { len:'{{trial_len}}' })} + .col-md-6 + h3 #{translate('faq_change_plans_question')} + p #{translate('faq_change_plans_answer')} + .row + .col-md-6 + h3 #{translate('faq_do_collab_need_premium_question')} + p #{translate('faq_do_collab_need_premium_answer')} + .col-md-6 + h3 #{translate('faq_need_more_collab_question')} + p !{translate('faq_need_more_collab_answer', { referFriendsLink: '' + translate('referring_your_friends') + ''})} + .row + .col-md-6 + h3 #{translate('faq_purchase_more_licenses_question')} + p !{translate('faq_purchase_more_licenses_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })} + .col-md-6 + h3 #{translate('faq_monthly_or_annual_question')} + p #{translate('faq_monthly_or_annual_answer')} + .row + .col-md-6 + h3 #{translate('faq_how_to_pay_question')} + p #{translate('faq_how_to_pay_answer')} + .col-md-6 + h3 #{translate('faq_pay_by_invoice_question')} + p !{translate('faq_pay_by_invoice_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })} + .row.row-spaced-large.text-centery + .col-md-12 + .plans-header.plans-subheader.text-centered + h2 #{translate('still_have_questions')} + button.btn.btn-info.btn-header.text-capitalize(ng-controller="ContactGeneralModal" ng-click="openModal()") #{translate('get_in_touch')} + != moduleIncludes("contactModalGeneral", locals) diff --git a/services/web/app/views/subscriptions/_plans_page_mixins.pug b/services/web/app/views/subscriptions/_plans_page_mixins.pug new file mode 100644 index 0000000000..c1f19bca00 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_mixins.pug @@ -0,0 +1,162 @@ +//- Buy Buttons +mixin btn_buy_collaborator(location) + a.btn.btn-info( + ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", + ng-click="signUpNowClicked('collaborator','" + location + "')" + ) + span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} + span(ng-show="ui.view == 'annual'") #{translate("buy_now")} +mixin btn_buy_free(location) + a.btn.btn-info( + href="/register" + style=(getLoggedInUserId() === null ? "" : "visibility: hidden") + ng-click="signUpNowClicked('free','" + location + "')" + ) + span(ng-if="plansVariant !== 'more-details'") #{translate('sign_up_now')} + span.text-capitalize(ng-if="plansVariant === 'more-details'") #{translate('get_started_now')} +mixin btn_buy_professional(location) + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}" + ng-click="signUpNowClicked('professional','" + location + "')" + ) + span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} + span(ng-show="ui.view == 'annual'") #{translate("buy_now")} +mixin btn_buy_student(location, plan) + if plan == 'annual' + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=student-annual¤cy={{currencyCode}}", + ng-click="signUpNowClicked('student-annual','" + location + "')" + ) #{translate("buy_now")} + else + //- planQueryString will contain _free_trial_7_days + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=student{{planQueryString}}¤cy={{currencyCode}}", + ng-click="signUpNowClicked('student-monthly','" + location + "')" + ) #{translate("start_free_trial")} + +//- Cards +mixin card_student_annual + .best-value(ng-if="plansVariant == 'more-details'") + strong #{translate('best_value')} + .card-header + h2 #{translate("student")} (#{translate("annual")}) + h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_annual')} + .circle + span + +price_student_annual + +features_student('card', 'annual') +mixin card_student_monthly + .card-header + h2 #{translate("student")} + h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_monthly')} + .circle + span + +price_student_monthly + +features_student('card', 'monthly') + +//- Features Lists +mixin features_collaborator + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:10})} + +features_premium + li + br + +btn_buy_collaborator('card') +mixin features_free + ul.list-unstyled + li #{translate("one_collaborator")} + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li + br + +btn_buy_free('card') +mixin features_premium + li(ng-if="plansVariant != 'more-details'") #{translate("full_doc_history")} + li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_dropbox")} + li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_github")} + li(ng-if="plansVariant === 'more-details'")   + li(ng-if="plansVariant === 'more-details'") + strong #{translate('all_premium_features')} + li(ng-if="plansVariant === 'more-details'") #{translate('sync_dropbox_github')} + li(ng-if="plansVariant === 'more-details'") #{translate('full_doc_history')} + li(ng-if="plansVariant === 'more-details'") #{translate('track_changes')} + li(ng-if="plansVariant === 'more-details'") + #{translate('more').toLowerCase()} +mixin features_professional + ul.list-unstyled + li + strong #{translate("unlimited_collabs")} + +features_premium + li + br + +btn_buy_professional('card') +mixin features_student(location, plan) + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:6})} + +features_premium + li + br + +btn_buy_student(location, plan) + +//- Prices +mixin price_collaborator + span(ng-if="ui.view == 'monthly'") + | {{plans[currencyCode]['collaborator']['monthly']}} + span.small /mo + span(ng-if="ui.view == 'annual'") + | {{plans[currencyCode]['collaborator']['annual']}} + span.small /yr +mixin price_professional + span(ng-if="ui.view == 'monthly'") + | {{plans[currencyCode]['professional']['monthly']}} + span.small /mo + span(ng-if="ui.view == 'annual'") + | {{plans[currencyCode]['professional']['annual']}} + span.small /yr +mixin price_student_annual + | {{plans[currencyCode]['student']['annual']}} + span.small /yr +mixin price_student_monthly + | {{plans[currencyCode]['student']['monthly']}} + span.small /mo + +//- UI Control +mixin currency_dropdown + .dropdown.currency-dropdown(dropdown) + a.btn.btn-default.dropdown-toggle( + href="#", + data-toggle="dropdown", + dropdown-toggle + ) + | {{currencyCode}} ({{plans[currencyCode]['symbol']}}) + span.caret + + ul.dropdown-menu.dropdown-menu-right.text-right(role="menu") + li(ng-repeat="(currency, value) in plans") + a( + href="#", + ng-click="changeCurreny($event, currency)" + ) {{currency}} ({{value['symbol']}}) +mixin plan_switch(location) + ul.nav.nav-pills + li(ng-class="{'active': ui.view == 'monthly'}") + a( + href="#" + ng-click="switchToMonthly($event,'" + location + "')" + ) #{translate("monthly")} + li(ng-class="{'active': ui.view == 'annual'}") + a( + href="#" + ng-click="switchToAnnual($event,'" + location + "')" + ) #{translate("annual")} + li(ng-class="{'active': ui.view == 'student'}") + a( + href="#" + ng-click="switchToStudent($event,'" + location + "')" + ) #{translate("half_price_student")} + diff --git a/services/web/app/views/subscriptions/_plans_page_tables.pug b/services/web/app/views/subscriptions/_plans_page_tables.pug new file mode 100644 index 0000000000..63c4747603 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_tables.pug @@ -0,0 +1,107 @@ + +//- Features Tables +mixin table_premium + table.card.plans-table + tr + th + th #{translate("personal")} + th #{translate("collaborator")} + .outer.outer-top + .outer-content + .best-value + strong #{translate('best_value')} + th #{translate("professional")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_collaborator + td + +price_professional + + for feature in planFeatures + tr + td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + for plan in feature.plans + td + if feature.value == 'str' + | #{plan} + else if plan + i.fa.fa-check + else + i.fa.fa-times + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_collaborator('table') + .outer.outer-btm + .outer-content   + td + +btn_buy_professional('table') + +mixin table_cell_student(feature) + if feature.value == 'str' + | #{feature.student} + else if feature.student + i.fa.fa-check + else + i.fa.fa-times + +mixin table_student + table.card.plans-table + tr + th + th #{translate("personal")} + th #{translate("student")} (#{translate("annual")}) + .outer.outer-top + .outer-content + .best-value + strong Best Value + th #{translate("student")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_student_annual + td + +price_student_monthly + + for feature in planFeatures + tr + td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + td + if feature.value == 'str' + | #{feature.plans.free} + else if feature.plans.free + i.fa.fa-check + else + i.fa.fa-times + td + +table_cell_student(feature) + td + +table_cell_student(feature) + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_student('table', 'annual') + .outer.outer-btm + .outer-content   + td + +btn_buy_student('table', 'monthly') + diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug index d86bdb8166..4eae442f1e 100644 --- a/services/web/app/views/subscriptions/new.pug +++ b/services/web/app/views/subscriptions/new.pug @@ -31,7 +31,10 @@ block content li(ng-repeat="(currency, value) in plans") a( ng-click="changeCurrency(currency)", - ) {{currency}} ({{value['symbol']}}) + ) {{currency}} ({{value['symbol']}}) + .row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'") + .col-xs-12 + p.student-disclaimer #{translate('student_disclaimer')} hr.thin .row .col-md-12.text-center diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 7c40c38a52..56a20f90f5 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -1,253 +1,18 @@ extends ../layout + +include _plans_page_mixins +include _plans_page_tables + block scripts script(type='text/javascript'). window.recomendedCurrency = '#{recomendedCurrency}' window.abCurrencyFlag = '#{abCurrencyFlag}' window.shouldABTestPlans = #{shouldABTestPlans || false} - script(type='text/javascript'). - (function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; - s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js'; - var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})(); - block content .content.content-alt .content.plans(ng-controller="PlansController") - .container - .row - .col-md-12 - .page-header.centered.plans-header.text-centered - h1(ng-cloak) #{translate("start_x_day_trial", {len:'{{trial_len}}'})} - .row - .col-md-8.col-md-offset-2 - p.text-centered #{translate("sl_benefits_plans")} - - .row(ng-cloak) - .col-md-6.col-md-offset-3 - ul.nav.nav-pills - li(ng-class="{'active': ui.view == 'monthly'}") - a( - href, - ng-click="switchToMonthly()" - ) #{translate("monthly")} - li(ng-class="{'active': ui.view == 'annual'}") - a( - href - ng-click="switchToAnnual()" - ) #{translate("annual")} - li(ng-class="{'active': ui.view == 'student'}") - a( - href, - ng-click="switchToStudent()" - ) #{translate("half_price_student")} - .col-md-2.text-right - .dropdown.currency-dropdown(dropdown) - a.btn.btn-default.dropdown-toggle#currenyDropdown( - href="#", - data-toggle="dropdown", - dropdown-toggle - ) - | {{currencyCode}} ({{plans[currencyCode]['symbol']}}) - span.caret - - ul.dropdown-menu.dropdown-menu-right.text-right(role="menu") - li(ng-repeat="(currency, value) in plans") - a( - href, - ng-click="changeCurreny(currency)" - ) {{currency}} ({{value['symbol']}}) - - div(ng-show="showPlans") - .row(ng-cloak) - .col-md-10.col-md-offset-1 - .row - .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") - .col-md-4 - .card.card-first - .card-header - h2 #{translate("personal")} - .circle #{translate("free")} - ul.list-unstyled - li #{translate("one_collaborator")} - li   - li   - li   - li - br - a.btn.btn-info( - href="/register" - style=(getLoggedInUserId() === null ? "" : "visibility: hidden") - ) #{translate("sign_up_now")} - .col-md-4 - .card.card-highlighted - .card-header - h2 #{translate("collaborator")} - .circle - span(ng-if="ui.view == 'monthly'") - | {{plans[currencyCode]['collaborator']['monthly']}} - span.small /mo - span(ng-if="ui.view == 'annual'") - | {{plans[currencyCode]['collaborator']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:10})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", ng-click="signUpNowClicked('collaborator')" - ) - span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} - span(ng-show="ui.view == 'annual'") #{translate("buy_now")} - .col-md-4 - .card.card-last - .card-header - h2 #{translate("professional")} - .circle - span(ng-if="ui.view == 'monthly'") - | {{plans[currencyCode]['professional']['monthly']}} - span.small /mo - span(ng-if="ui.view == 'annual'") - | {{plans[currencyCode]['professional']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("unlimited_collabs")} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('professional')" - ) - span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} - span(ng-show="ui.view == 'annual'") #{translate("buy_now")} - - .card-group.text-centered(ng-if="ui.view == 'student'") - .col-md-4 - .card.card-first - .card-header - h2 #{translate("personal")} - .circle #{translate("free")} - ul.list-unstyled - li #{translate("one_collaborator")} - li   - li   - li   - li - br - a.btn.btn-info( - href="/register" - style=(getLoggedInUserId() === null ? "" : "visibility: hidden") - ) #{translate("sign_up_now")} - - .col-md-4 - .card.card-highlighted - .card-header - h2 #{translate("student")} - .circle - span - | {{plans[currencyCode]['student']['monthly']}} - span.small /mo - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:6})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}¤cy={{currencyCode}}", - ng-click="signUpNowClicked('student-monthly')" - ) #{translate("start_free_trial")} - - .col-md-4 - .card.card-last - .card-header - h2 #{translate("student")} (#{translate("annual")}) - .circle - span - | {{plans[currencyCode]['student']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:6})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}¤cy={{currencyCode}}", - ng-click="signUpNowClicked('student-annual')" - ) #{translate("buy_now")} - - - - .row.row-spaced(ng-cloak) - p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})} - - .row(ng-cloak) - .col-md-8.col-md-offset-2 - .alert.alert-info.text-centered - | #{translate("interested_in_group_licence")} - br - a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")} - - script(type="text/ng-template", id="groupPlanModalTemplate") - .modal-header - h3 #{translate("group_plan_enquiry")} - .modal-body - form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak) - span(ng-show="sent == false && error == false") - .form-group - label#title9(for='Field9') - | Name - input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='') - label#title11.desc(for='Field11') - | Email - .form-group - input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') - label#title12.desc(for='Field12') - | University / Company - .form-group - input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') - label#title13.desc(for='Field13') - | Position - .form-group - input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') - .form-group - input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';") - .form-group.text-center - input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') - span(ng-show="sent == true && error == false") - p Request Sent, Thank you. - span(ng-show="error") - p Error sending request. - - .row - .col-md-12 - .page-header.plans-header.plans-subheader.text-centered - h2 #{translate("enjoy_these_features")} - .col-md-4 - .card.features.text-centered - i.fa.fa-file-text-o.fa-5x - h4 #{translate("unlimited_projects")} - p #{translate("create_unlimited_projects")} - .col-md-4 - .card.features.text-centered - i.fa.fa-clock-o.fa-5x - h4 #{translate("full_doc_history")} - p #{translate("never_loose_work")} - .col-md-4 - .card.features.text-centered - i.fa.fa-dropbox.fa-5x - |     - i.fa.fa-github.fa-5x - h4 #{translate("sync_to_dropbox_and_github")} - p #{translate("access_projects_anywhere")} + .container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'") + include _plans_page_details_more + .container(ng-cloak ng-if="plansVariant != 'more-details'") + include _plans_page_details_less diff --git a/services/web/public/coffee/main/event.coffee b/services/web/public/coffee/main/event.coffee index 3ff8fc0aa4..22b7cbb4b6 100644 --- a/services/web/public/coffee/main/event.coffee +++ b/services/web/public/coffee/main/event.coffee @@ -93,4 +93,4 @@ define [ $('.navbar a').on "click", (e)-> href = $(e.target).attr("href") if href? - ga('send', 'event', 'navigation', 'top menu bar', href) + ga('send', 'event', 'navigation', 'top menu bar', href) \ No newline at end of file diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee index 31d8e37f40..7851171524 100644 --- a/services/web/public/coffee/main/new-subscription.coffee +++ b/services/web/public/coffee/main/new-subscription.coffee @@ -9,6 +9,7 @@ define [ $scope.currencyCode = MultiCurrencyPricing.currencyCode $scope.plans = MultiCurrencyPricing.plans + $scope.planCode = window.plan_code $scope.switchToStudent = ()-> currentPlanCode = window.plan_code @@ -234,3 +235,6 @@ define [ {code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'}, {code:'ZM',name:'Zambia'},{code:'AX',name:'Åland Islandscode:'} ] + + sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)-> + $scope.plansVariant = chosenVariation \ No newline at end of file diff --git a/services/web/public/coffee/main/plans.coffee b/services/web/public/coffee/main/plans.coffee index 9a62420d66..7eb63607c0 100644 --- a/services/web/public/coffee/main/plans.coffee +++ b/services/web/public/coffee/main/plans.coffee @@ -3,7 +3,6 @@ define [ "libs/recurly-4.8.5" ], (App, recurly) -> - App.factory "MultiCurrencyPricing", () -> currencyCode = window.recomendedCurrency @@ -146,17 +145,16 @@ define [ } - App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) -> + App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) -> $scope.showPlans = false - - $scope.plansVariant = 'default' $scope.shouldABTestPlans = window.shouldABTestPlans if $scope.shouldABTestPlans - $scope.showPlans = true - else - $scope.showPlans = true + sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)-> + $scope.plansVariant = chosenVariation + + $scope.showPlans = true $scope.plans = MultiCurrencyPricing.plans @@ -169,44 +167,57 @@ define [ $scope.ui = view: "monthly" - $scope.changeCurreny = (newCurrency)-> + $scope.changeCurreny = (e, newCurrency)-> + e.preventDefault() $scope.currencyCode = newCurrency # because ternary logic in angular bindings is hard $scope.getCollaboratorPlanCode = () -> view = $scope.ui.view - variant = $scope.plansVariant if view == "annual" - if variant == "default" - return "collaborator-annual" - else - return "collaborator-annual_#{variant}" + return "collaborator-annual" else - if variant == "default" - return "collaborator#{$scope.planQueryString}" - else - return "collaborator_#{variant}" + return "collaborator#{$scope.planQueryString}" - $scope.signUpNowClicked = (plan, annual)-> - event_tracking.sendMB 'plans-page-start-trial', {plan} + $scope.signUpNowClicked = (plan, location)-> if $scope.ui.view == "annual" plan = "#{plan}_annual" - event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan + plan = eventLabel(plan, location) + event_tracking.sendMB 'plans-page-start-trial', {plan} + event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan + if $scope.shouldABTestPlans + sixpack.convert 'plans-details' - $scope.switchToMonthly = -> - $scope.ui.view = "monthly" - event_tracking.send 'subscription-funnel', 'plans-page', 'monthly-prices' + $scope.switchToMonthly = (e, location) -> + uiView = 'monthly' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView - $scope.switchToStudent = -> - $scope.ui.view = "student" - event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices' + $scope.switchToStudent = (e, location) -> + uiView = 'student' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView - $scope.switchToAnnual = -> - $scope.ui.view = "annual" - event_tracking.send 'subscription-funnel', 'plans-page', 'annual-prices' + $scope.switchToAnnual = (e, location) -> + uiView = 'annual' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView $scope.openGroupPlanModal = () -> $modal.open { templateUrl: "groupPlanModalTemplate" } event_tracking.send 'subscription-funnel', 'plans-page', 'group-inquiry-potential' + + eventLabel = (label, location) -> + if location && $scope.plansVariant != 'default' + label = label + '-' + location + if $scope.plansVariant != 'default' + label += '-exp-' + $scope.plansVariant + label + + switchEvent = (e, label, location) -> + e.preventDefault() + gaLabel = eventLabel(label, location) + event_tracking.send 'subscription-funnel', 'plans-page', gaLabel + diff --git a/services/web/public/img/advocates/erdogmus.jpg b/services/web/public/img/advocates/erdogmus.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e70a641fd0394154e823a62b786f66722fd6481c GIT binary patch literal 35341 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�qs31cb@CaW+UU5B zB~^nz`yf?6qjix={>W;R<&57{vtq4;x*=3=;!Y*v<8U##(rz9VNS71o+x7X$dvlEfPB(djBo;R3l7c{}E2OKFLYj`Z) zwkVQeK*@m8KC(5WnTa(gNju2dvfVx0?Ci~==%w>XlJ4(HnHJ9v+7qqRe)SXdIjr$p z_G1fQymT5ej|oaFLTSX+6{m+RYV(eW4%4=Ykob%0b**x+NVXIpR}P59M}Py!^x0GKV=I`*4BN`jkCRt)n@(iV7g^XWG5ZOh@nF> zRv{Xu0R$^mrs-aE1x(w?Rx`s|<3b=m9{y{?rOi5T8ZZsTpYjWQpUM16C5*LTsEg}* z_C1Nc_Lc(1oZm~xzTt5A@a)))L06{5C~A@f!?-3UdTN*dUG;u#*g9=z@*@zXN7^Et z^zK4=RKV-W_-NlKkX3$bQy0kasC;o&ywy|>y=2J_{l^zB$vbYVo&}YBl!rdr74)}a6|5bv zGu36|+{E@Z87Hrl(1#u#xbsNk)-kJ@ZPdGPZzhS4Iy!c68_wvQbV<#zuYNKeOkWiZ zWDQ-I9US5JMvWHKkw*Eg%M@H*FQi6eH8Yx((-;IU1m117d4X1&Pg#AXIL~2U`L^P} zbIbXUwY6_(1SAck1oct0T-%#?h(P8$Vlb&lk$gW#Mm=IvqW5;Uo_{BebYJK=um_h) zprRQdVa11N{um62I;D|vLPAa~DCkYqjF9iK47Tp#v#e3UShFY$VP5*AqYG@Go{=JdT-dupr`#YO05`MqRuihQcn6U5bWPq-2h zSZ!8Q9f49QBDX=z5lya9e$0b+y$xdM8EGxB<f=#Snq0hdVMVR&KnzS5a>d-&B@vU-GHL`naccz3)H2UW#$0xr2T#sXD;H6 zDVOR+fSuzfWQ5txU6V1 z?emXgQ5Q-{Hgf{S?oHwbCzsz&6QdX2 zu==tW?l)WxJf-A5v?xvH7La4+nCZI5T__WC6Rr+{ZvneVcCIwbL5RU*iD~X@`nH&? zCHIR8u_C<1;V5M!gR<;V!#cnFE;sF`(KY_8>d|4ZlXm(V#~(Y>Sbz*BzO`XwS zDaU(_ECyhb`daN?vL%pbrN)(xP@AOqTAfN7zMeB0C=+BL?G+0Co)Q zFzDKa@BsY@+hA@ffX3vhkvQY1Sb+_fW$y1Wredj^nw?P*+2Xa5!oK-~C59LG^81?# zoTx~=-)l0L_N~UD1|idb0eaT!V=7;Dw6^XZU%wd361k4@!HxS*1-p;VpEHU~=%#2a90-QZ|9gBI@DP@Cf2JAB8g z0FeZNd>#?;MnV2gKIy*zeRMch*WyX5DXJ-04yIUObZ1TXn zKZ)U%dT;=~iT@U6e|0cuiEA>?niKnq&@9kSAk3s@OyvusFEX-@tz~;$=W5A z*V2~Y}+L?_SKtdmMdmeq6uP1cpQ(T z-t1(`zv-@ZV;bZ6bJ8lciT6O!lRX1EJ1#B;p}}EQbX~G$(?ozO=>{u1Iy)GTO*z># ze3Jh9rez6*yv>~NWDqde%vcP z3gMewtQxrE?>x^NkHZ43d>Vy>sM^iR@2D#b}YOYe) zy?RR1YTd;mq0DRd;bMLQd527v#EmiVvrl}gcmqqJ5I@g==VvWr`>{7rBm6tvr55xe zy|KEW3RZF$KinPu%4}&6!1k>5WtnjgpDq z1?wYKY)U(~iDZS#GxVGQIV3Py={1*bmq>s=5j9CS;17^ zPChA;(afcCW9GE(Vsu&2K0yhb*(K-|hNx3sNYcJiGJD0IQoNKjrudT&%e{l+HBy6{ zrRjy-C!WGgFp{oZZQ_n6_tcCPpPnOz^YZ;UQkTN-=txesm^zsw!%@8E+7=L)Zh88? zkoaVQ4bxC4f z%>5OWMMQUZ!1VG>LcP>Vk1b4gHbs%|kWcR4v%53OMG=GSrD}*=DZ_ z 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 W>Hh%Yx8|#O<~0(`{{Y6C3IEw7IfBXn literal 0 HcmV?d00001 diff --git a/services/web/public/stylesheets/app/plans.less b/services/web/public/stylesheets/app/plans.less index b830db9b23..41e2fc80f1 100644 --- a/services/web/public/stylesheets/app/plans.less +++ b/services/web/public/stylesheets/app/plans.less @@ -32,7 +32,7 @@ padding-bottom: @line-height-computed * 2; } } - + .circle { font-size: 1.5rem; font-weight: 700; @@ -63,6 +63,12 @@ } .card .btn { white-space:normal; } + + .top-switch { + .currency-dropdown { + margin-right: -15px; + } + } } #changePlanSection { @@ -127,4 +133,261 @@ input.paymentTypeOption.ng-valid { text-align: right; } +/** + Plans Test +*/ +@best-val-height: 35px; +@highlight-border: 3px; +@highlight-color: #d3584b; +@gray-med: #6d6d6d; +@white-med: #fdfdfd; +.more-details { + .best-value { + color: @red; + line-height: @line-height-computed; + } + blockquote { + footer{ + /* accessibility fix */ + color: @gray-med; + } + } + .btn-header { + font-family: @font-family-sans-serif; + margin-left: 10px; + margin-top: -10px; + text-shadow: 0 0 0; + } + .card-first, .card-last { + background: @white-med; + } + .card-highlighted { + border: @highlight-border solid @gray-lighter; + padding-top: 10px!important; + .best-value { + margin-bottom: 15px; + } + .card-header { + padding-bottom: 22px; /* align hr with other plans */ + } + } + .card-header { + margin-bottom: 15px; + } + .circle { + /* accessibility fix */ + span.small { + color: rgba(255, 255, 255, 0.85) + } + } + .circle-img { + border-radius: 50%; + float: right; + height: 100px; + overflow: hidden; + position: relative; + width: 100px; + img { + display: inline; + margin: 0 auto; + width: 100%; + } + } + .faq:last-child { + p { + margin-bottom: 0; + } + } + .questions-header { + color: @red; + line-height: 37px; + margin: 0; + text-align: right; + } + .tagline { + margin-bottom: 20px; + } + /* Media Queries */ + @media (max-width: @screen-md-min) { + .card-highlighted { + /*override style in cards.less */ + margin-top: @line-height-computed!important; + } + .circle-img { + float: left; + margin: 0 15px; + } + } + @media (min-width: @screen-md-min) { + blockquote { + margin-bottom: 0; + } + .faq { + .row:nth-child(2) { + h3 { + margin-top: 0; + } + } + } + } +} +.student-disclaimer { + font-size: 14px; /* match .paymentPageFeatures p */ + color: @gray; /* match .paymentPageFeatures p */ + margin: 12.5px 0 0 0; +} + +/** + Plans Table +*/ +.plans-table { + border: 1px solid @gray-lighter; + background-color: @white-med; + margin: @best-val-height 0 15px 0; + table-layout: fixed; + width: 100%; + + th, td { + -moz-background-clip: padding; + -webkit-background-clip: padding; + background-clip: padding-box; /* needed for firefox when there is bg color */ + border: 1px solid @gray-lighter; + padding: 6px; + text-align: center; + vertical-align: middle; + } + + td { + font-weight: bold; + } + + th { + border-top: 0; + font-family: @headings-font-family; + font-size: @font-size-h2; + font-weight: @headings-font-weight; + line-height: @headings-line-height; + padding: 18px; + } + + th:first-child, td:first-child { + border-left: 0; + } + + th:last-child, td:last-child { + border-right: 0; + } + + td:first-child { + font-weight: bold; + padding-left: 18px; + text-align: left; + } + + tr:first-child { + th { + position: relative; + /* keep here position here, otherwise messes up border on safari */ + } + } + + tr:last-child { + td { + border-bottom: 0; + padding: 18px; + } + /* highlighted column */ + td:nth-child(3) { + position: relative; + /* keep here position here, otherwise messes up border on safari when there is a bg color */ + &:before { + /* needed for safafi */ + border-top: 1px solid @gray-lighter; + content: ''; + left: 0; + position: absolute; + top: -1px; + width: 100%; + } + } + td:first-child { + border: 0; + } + } + + .fa-check { + color: @green; + } + + /* accessibility fixes */ + .small { + color: @gray-med; + } + + /* highlighted column */ + td:nth-child(3), th:nth-child(3) { + background-color: white; + border-left: @highlight-border solid @gray-lighter; + border-right: @highlight-border solid @gray-lighter; + } + .outer { + left: -@highlight-border; + right: -@highlight-border; + position: absolute; + + .outer-content { + background: white; + border: @highlight-border solid @gray-lighter; + border-radius: @border-radius-base; + font-size: @font-size-base; + font-family: @font-family-sans-serif; + font-weight: bold; + height: @best-val-height; + padding-top: 10px; + } + } + .outer.outer-top { + top: -@best-val-height; + .outer-content { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; + } + } + .outer.outer-btm { + bottom: -@best-val-height/2; + .outer-content { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 0; + height: @best-val-height/2; + } + } + + /* highlight rows on hover */ + tr:hover { + td { + background-color: @gray-lightest; + } + } + tr:first-child:hover { + background-color: transparent; + } + tr:last-child:hover { + background-color: transparent; + td { + background-color: transparent; + } + } + + /* tooltip */ + sup { + color: @red; + cursor: pointer; + margin-left: 5px; + } + .tooltip.in { + min-width: 200px + } +} diff --git a/services/web/public/stylesheets/core/scaffolding.less b/services/web/public/stylesheets/core/scaffolding.less index df52ab1fb4..abadcabae9 100755 --- a/services/web/public/stylesheets/core/scaffolding.less +++ b/services/web/public/stylesheets/core/scaffolding.less @@ -161,4 +161,7 @@ hr { margin-top: @line-height-computed / 2; } +.row-spaced-large { + margin-top: @line-height-computed * 2; +} diff --git a/services/web/public/stylesheets/core/type.less b/services/web/public/stylesheets/core/type.less index ac1dcf3765..c8f2dcb9f1 100755 --- a/services/web/public/stylesheets/core/type.less +++ b/services/web/public/stylesheets/core/type.less @@ -122,6 +122,11 @@ cite { font-style: normal; } text-align: center; } +// Transformations +.text-capitalize { + text-transform: capitalize; +} + // Contextual backgrounds // For now we'll leave these alongside the text classes until v4 when we can // safely shift things around (per SemVer rules). @@ -256,7 +261,14 @@ blockquote { vertical-align: -0.4em; line-height: 0.1em; } - + + &:after { + content: close-quote; + display: inherit; + height: 0; + visibility: hidden; + } + p { display: inline; } From b1c988e4c18bae2d514e4e1312c765266a5cdc81 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Wed, 30 May 2018 11:42:44 -0500 Subject: [PATCH 106/135] Add hover and scroll events --- .../coffee/directives/eventTracking.coffee | 44 ++++++++++++++++--- services/web/public/coffee/main/event.coffee | 9 +++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/services/web/public/coffee/directives/eventTracking.coffee b/services/web/public/coffee/directives/eventTracking.coffee index 9ba2fbb647..2710960fe0 100644 --- a/services/web/public/coffee/directives/eventTracking.coffee +++ b/services/web/public/coffee/directives/eventTracking.coffee @@ -4,11 +4,23 @@ # event not sent to MB. # for MB, add event-tracking-mb='true' # by default, event sent to MB via sendMB -# this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute # event not sent to GA. # for GA, add event-tracking-ga attribute, where the value is the GA category +# Either GA or MB can use the attribute event-tracking-send-once='true' to +# send event just once +# MB will use the key and GA will use the action to determine if the event +# has been sent # event-tracking-trigger attribute is required to send event +isInViewport = (element) -> + elTop = element.offset().top + elBtm = elTop + element.outerHeight() + + viewportTop = $(window).scrollTop() + viewportBtm = viewportTop + $(window).height() + + elBtm > viewportTop && elTop < viewportBtm + define [ 'base' ], (App) -> @@ -22,20 +34,42 @@ define [ sendGA = attrs.eventTrackingGa || false sendMB = attrs.eventTrackingMb || false sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB' + sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send' segmentation = scope.eventSegmentation || {} - segmentation.page = window.location.pathname - sendEvent = () -> + sendEvent = (scrollEvent) -> + ### + @param {boolean} scrollEvent Use to unbind scroll event + ### if sendMB event_tracking[sendMBFunction] scope.eventTracking, segmentation if sendGA - event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || '' + event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || '' + if scrollEvent + $(window).unbind('resize scroll') if attrs.eventTrackingTrigger == 'load' sendEvent() else if attrs.eventTrackingTrigger == 'click' element.on 'click', (e) -> sendEvent() + else if attrs.eventTrackingTrigger == 'hover' + timer = null + timeoutAmt = 500 + if attrs.eventHoverAmt + timeoutAmt = parseInt(attrs.eventHoverAmt, 10) + element.on 'mouseenter', () -> + timer = setTimeout((-> sendEvent()), timeoutAmt) + return + .on 'mouseleave', () -> + clearTimeout(timer) + else if attrs.eventTrackingTrigger == 'scroll' + if !event_tracking.eventInCache(scope.eventTracking) + $(window).on 'resize scroll', () -> + _.throttle( + if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking) + sendEvent(true) + , 500) } - ] \ No newline at end of file + ] diff --git a/services/web/public/coffee/main/event.coffee b/services/web/public/coffee/main/event.coffee index 22b7cbb4b6..d4e07c7fc8 100644 --- a/services/web/public/coffee/main/event.coffee +++ b/services/web/public/coffee/main/event.coffee @@ -50,6 +50,10 @@ define [ send: (category, action, label, value)-> ga('send', 'event', category, action, label, value) + sendGAOnce: (category, action, label, value) -> + if ! _eventInCache(action) + _addEventToCache(action) + @send category, action, label, value editingSessionHeartbeat: () -> return unless nextHeartbeat <= new Date() @@ -86,6 +90,9 @@ define [ if ! _eventInCache(key) _addEventToCache(key) @sendMB key, segmentation + + eventInCache: (key) -> + _eventInCache(key) } @@ -93,4 +100,4 @@ define [ $('.navbar a').on "click", (e)-> href = $(e.target).attr("href") if href? - ga('send', 'event', 'navigation', 'top menu bar', href) \ No newline at end of file + ga('send', 'event', 'navigation', 'top menu bar', href) From 8b672dea0d142881933421ffdcf32e118d931884 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 31 May 2018 16:01:01 -0500 Subject: [PATCH 107/135] Add unit tests --- .../SubscriptionControllerTests.coffee | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee index 04888e2dd7..1592215040 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee @@ -87,35 +87,69 @@ describe "SubscriptionController", -> @stubbedCurrencyCode = "GBP" describe "plansPage", -> - beforeEach (done) -> + beforeEach -> @req.ip = "1234.3123.3131.333 313.133.445.666 653.5345.5345.534" @GeoIpLookup.getCurrencyCode.callsArgWith(1, null, @stubbedCurrencyCode) - @res.callback = done - @SubscriptionController.plansPage(@req, @res) - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) - it "should set the recommended currency from the geoiplookup", (done)-> - @res.renderedVariables.recomendedCurrency.should.equal(@stubbedCurrencyCode) - @GeoIpLookup.getCurrencyCode.calledWith(@req.ip).should.equal true - done() + describe 'when user is logged in', (done) -> + beforeEach (done) -> + @res.callback = done + @SubscriptionController.plansPage(@req, @res) + it 'should fetch the current user', (done) -> + @UserGetter.getUser.callCount.should.equal 1 + done() - it 'should fetch the current user', (done) -> - @UserGetter.getUser.callCount.should.equal 1 - done() + it 'should decide not to AB test the plans when signed up before 2016-10-27', (done) -> + @res.renderedVariables.shouldABTestPlans.should.equal false + done() - it 'should decide not to AB test the plans', (done) -> - @res.renderedVariables.shouldABTestPlans.should.equal false - done() + describe 'not dependant on logged in state', (done) -> + # these could have been put in 'when user is not logged in' too + it "should set the recommended currency from the geoiplookup", (done)-> + @res.renderedVariables.recomendedCurrency.should.equal(@stubbedCurrencyCode) + @GeoIpLookup.getCurrencyCode.calledWith(@req.ip).should.equal true + done() + it 'should include data for features table', (done) -> + # this is part of AB test. If default wins test, then remove this test + @res.renderedVariables.planFeatures.length.should.not.equal 0 + done() describe 'when user is not logged in', (done) -> + beforeEach (done) -> + @UserGetter = + getUser: sinon.stub().callsArgWith(2, null, null) + @res.callback = done + @SubscriptionController.plansPage(@req, @res) + @AuthenticationController = + getLoggedInUser: sinon.stub().callsArgWith(1, null, null) + getLoggedInUserId: sinon.stub().returns(null) + getSessionUser: sinon.stub().returns(null) + isUserLoggedIn: sinon.stub().returns(false) - beforeEach -> - @AuthenticationController.getLoggedInUserId.returns(null) + @SubscriptionController = SandboxedModule.require modulePath, requires: + '../Authentication/AuthenticationController': @AuthenticationController + './SubscriptionHandler': @SubscriptionHandler + "./PlansLocator": @PlansLocator + './SubscriptionViewModelBuilder': @SubscriptionViewModelBuilder + "./LimitationsManager": @LimitationsManager + "../../infrastructure/GeoIpLookup":@GeoIpLookup + "logger-sharelatex": + log:-> + warn:-> + "settings-sharelatex": @settings + "./SubscriptionDomainHandler":@SubscriptionDomainHandler + "../User/UserGetter": @UserGetter + "./RecurlyWrapper": @RecurlyWrapper = {} + "./FeaturesUpdater": @FeaturesUpdater = {} it 'should not fetch the current user', (done) -> @UserGetter.getUser.callCount.should.equal 0 done() + it 'should decide to AB test', (done) -> + @res.renderedVariables.shouldABTestPlans.should.equal true + done() + describe "paymentPage", -> beforeEach -> @req.headers = {} @@ -460,4 +494,4 @@ describe "SubscriptionController", -> @SubscriptionHandler.updateSubscription.calledWith(@user, "collaborator-annual", "COLLABORATORCODEHERE").should.equal true done() - @SubscriptionController.processUpgradeToAnnualPlan @req, @res + @SubscriptionController.processUpgradeToAnnualPlan @req, @res \ No newline at end of file From f858786f2dffb0f632ebee3feaddadf769c7d57c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Jun 2018 10:14:16 +0100 Subject: [PATCH 108/135] Add i18n. --- .../project/editor/history/entriesListV1.pug | 8 ++++---- .../project/editor/history/entriesListV2.pug | 20 +++++++++++++------ .../project/editor/history/toolbarV2.pug | 4 ++-- .../history/components/historyEntry.coffee | 4 ---- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/services/web/app/views/project/editor/history/entriesListV1.pug b/services/web/app/views/project/editor/history/entriesListV1.pug index 2db6b9e533..361bca3679 100644 --- a/services/web/app/views/project/editor/history/entriesListV1.pug +++ b/services/web/app/views/project/editor/history/entriesListV1.pug @@ -53,18 +53,18 @@ aside.change-list( div.description(ng-click="select()") div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") - | Edited + | #{translate("file-action-edited")} div.docs(ng-repeat="pathname in update.pathnames") .doc {{ pathname }} div.docs(ng-repeat="project_op in update.project_ops") div(ng-if="project_op.rename") - .action Renamed + .action #{translate("file-action-renamed")} .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} div(ng-if="project_op.add") - .action Created + .action #{translate("file-action-created")} .doc {{ project_op.add.pathname }} div(ng-if="project_op.remove") - .action Deleted + .action #{translate("file-action-deleted")} .doc {{ project_op.remove.pathname }} div.users div.user(ng-repeat="update_user in update.meta.users") diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index 0256824293..088f1d2741 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -67,18 +67,18 @@ aside.change-list( div.description(ng-click="select()") div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") - | Edited + | #{translate("file-action-edited")} div.docs(ng-repeat="pathname in update.pathnames") .doc {{ pathname }} div.docs(ng-repeat="project_op in update.project_ops") div(ng-if="project_op.rename") - .action Renamed + .action #{translate("file-action-renamed")} .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} div(ng-if="project_op.add") - .action Created + .action #{translate("file-action-created")} .doc {{ project_op.add.pathname }} div(ng-if="project_op.remove") - .action Deleted + .action #{translate("file-action-deleted")} .doc {{ project_op.remove.pathname }} div.users div.user(ng-repeat="update_user in update.meta.users") @@ -133,12 +133,20 @@ script(type="text/ng-template", id="historyEntryTpl") li.history-entry-change( ng-repeat="pathname in ::$ctrl.entry.pathnames" ) - span.history-entry-change-action Edited + span.history-entry-change-action #{translate("file-action-edited")} span.history-entry-change-doc {{ ::pathname }} li.history-entry-change( ng-repeat="project_op in ::$ctrl.entry.project_ops" ) - span.history-entry-change-action {{ ::$ctrl.getProjectOpAction(project_op) }} + span.history-entry-change-action( + ng-if="::project_op.rename" + ) #{translate("file-action-renamed")} + span.history-entry-change-action( + ng-if="::project_op.add" + ) #{translate("file-action-created")} + span.history-entry-change-action( + ng-if="::project_op.remove" + ) #{translate("file-action-deleted")} span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} .history-entry-metadata time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index 48f2292a16..f737798089 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -4,10 +4,10 @@ span(ng-show="history.loadingFileTree") i.fa.fa-spin.fa-refresh |    #{translate("loading")}... - span(ng-show="!history.loadingFileTree") Browsing project as of  + span(ng-show="!history.loadingFileTree") #{translate("browsing-project-as-of")}  time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} .history-toolbar-btn( ng-click="toggleHistoryViewMode();" ) i.fa - | Compare project versions \ No newline at end of file + | #{translate("compare-project-versions")} \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee index 53824c1317..e2692b7dee 100644 --- a/services/web/public/coffee/ide/history/components/historyEntry.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -5,10 +5,6 @@ define [ historyEntryController = ($scope, $element, $attrs) -> ctrl = @ ctrl.displayName = displayNameForUser - ctrl.getProjectOpAction = (projectOp) -> - if projectOp.rename? then "Renamed" - else if projectOp.add? then "Created" - else if projectOp.remove? then "Deleted" ctrl.getProjectOpDoc = (projectOp) -> if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }" else if projectOp.add? then "#{ projectOp.add.pathname}" From 4088c164c9032d6ac508d0b1f8c19ed1843d8988 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Jun 2018 11:15:39 +0100 Subject: [PATCH 109/135] Update translations; use underscores for keys. --- .../project/editor/history/entriesListV1.pug | 8 ++++---- .../project/editor/history/entriesListV2.pug | 16 ++++++++-------- .../project/editor/history/previewPanelV2.pug | 2 +- .../views/project/editor/history/toolbarV2.pug | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/services/web/app/views/project/editor/history/entriesListV1.pug b/services/web/app/views/project/editor/history/entriesListV1.pug index 361bca3679..27f9e66fe1 100644 --- a/services/web/app/views/project/editor/history/entriesListV1.pug +++ b/services/web/app/views/project/editor/history/entriesListV1.pug @@ -53,18 +53,18 @@ aside.change-list( div.description(ng-click="select()") div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") - | #{translate("file-action-edited")} + | #{translate("file_action_edited")} div.docs(ng-repeat="pathname in update.pathnames") .doc {{ pathname }} div.docs(ng-repeat="project_op in update.project_ops") div(ng-if="project_op.rename") - .action #{translate("file-action-renamed")} + .action #{translate("file_action_renamed")} .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} div(ng-if="project_op.add") - .action #{translate("file-action-created")} + .action #{translate("file_action_created")} .doc {{ project_op.add.pathname }} div(ng-if="project_op.remove") - .action #{translate("file-action-deleted")} + .action #{translate("file_action_deleted")} .doc {{ project_op.remove.pathname }} div.users div.user(ng-repeat="update_user in update.meta.users") diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index a7d078b5c8..fa7a90b20e 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -67,18 +67,18 @@ aside.change-list( div.description(ng-click="select()") div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") - | #{translate("file-action-edited")} + | #{translate("file_action_edited")} div.docs(ng-repeat="pathname in update.pathnames") .doc {{ pathname }} div.docs(ng-repeat="project_op in update.project_ops") div(ng-if="project_op.rename") - .action #{translate("file-action-renamed")} + .action #{translate("file_action_renamed")} .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} div(ng-if="project_op.add") - .action #{translate("file-action-created")} + .action #{translate("file_action_created")} .doc {{ project_op.add.pathname }} div(ng-if="project_op.remove") - .action #{translate("file-action-deleted")} + .action #{translate("file_action_deleted")} .doc {{ project_op.remove.pathname }} div.users div.user(ng-repeat="update_user in update.meta.users") @@ -133,20 +133,20 @@ script(type="text/ng-template", id="historyEntryTpl") li.history-entry-change( ng-repeat="pathname in ::$ctrl.entry.pathnames" ) - span.history-entry-change-action #{translate("file-action-edited")} + span.history-entry-change-action #{translate("file_action_edited")} span.history-entry-change-doc {{ ::pathname }} li.history-entry-change( ng-repeat="project_op in ::$ctrl.entry.project_ops" ) span.history-entry-change-action( ng-if="::project_op.rename" - ) #{translate("file-action-renamed")} + ) #{translate("file_action_renamed")} span.history-entry-change-action( ng-if="::project_op.add" - ) #{translate("file-action-created")} + ) #{translate("file_action_created")} span.history-entry-change-action( ng-if="::project_op.remove" - ) #{translate("file-action-deleted")} + ) #{translate("file_action_deleted")} span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} .history-entry-metadata time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index 596912d109..3d7a1ac3df 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -23,7 +23,7 @@ ng-click="toggleHistoryViewMode();" ) i.fa - | Browse project versions + | #{translate("view_single_version")} .toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV") button.btn.btn-danger.btn-xs( ng-click="restoreDeletedFile()" diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index f737798089..799a7136f3 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -4,10 +4,10 @@ span(ng-show="history.loadingFileTree") i.fa.fa-spin.fa-refresh |    #{translate("loading")}... - span(ng-show="!history.loadingFileTree") #{translate("browsing-project-as-of")}  + span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}  time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} .history-toolbar-btn( ng-click="toggleHistoryViewMode();" ) i.fa - | #{translate("compare-project-versions")} \ No newline at end of file + | #{translate("compare_to_another_version")} \ No newline at end of file From be0fd9a446f1f0a01170b52610f5774129e7351a Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Tue, 5 Jun 2018 11:30:48 +0100 Subject: [PATCH 110/135] reduce container teardown timeout to 0 --- services/web/Makefile | 6 +++--- services/web/Makefile.module | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index 469ab8e919..bfdfc0f52f 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -180,7 +180,7 @@ clean_css: rm -f public/stylesheets/*.css* clean_ci: - docker-compose down -v + docker-compose down -v -t 0 test: test_unit test_frontend test_acceptance @@ -204,7 +204,7 @@ test_acceptance_app_start_service: test_clean # stop service and clear dbs docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance test_acceptance_app_stop_service: - docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo + docker-compose ${DOCKER_COMPOSE_FLAGS} stop -t 0 test_acceptance redis mongo test_acceptance_app_run: docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS} @@ -224,7 +224,7 @@ test_acceptance_module: $(MODULE_MAKEFILES) fi test_clean: - docker-compose ${DOCKER_COMPOSE_FLAGS} down -v + docker-compose ${DOCKER_COMPOSE_FLAGS} down -v -t 0 ci: MOCHA_ARGS="--reporter tap" \ diff --git a/services/web/Makefile.module b/services/web/Makefile.module index 00bea5f8da..fa1ba41679 100644 --- a/services/web/Makefile.module +++ b/services/web/Makefile.module @@ -62,7 +62,7 @@ test_acceptance_start_service: test_acceptance_stop_service $(DOCKER_COMPOSE) up -d test_acceptance test_acceptance_stop_service: - $(DOCKER_COMPOSE) stop test_acceptance redis mongo + $(DOCKER_COMPOSE) stop -t 0 test_acceptance redis mongo test_acceptance_run: $(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js From 0bb37d4991e12dce6077487ffe76d15b04d41480 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Tue, 5 Jun 2018 13:41:17 +0100 Subject: [PATCH 111/135] move tk call to before sandboxed module call --- .../AuthenticationControllerTests.coffee | 2 +- .../unit/coffee/History/RestoreManagerTests.coffee | 2 +- .../coffee/Subscription/RecurlyWrapperTests.coffee | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index 92d2a7dbdb..1d8d8ab27d 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee @@ -12,6 +12,7 @@ ObjectId = require("mongojs").ObjectId describe "AuthenticationController", -> beforeEach -> + tk.freeze(Date.now()) @AuthenticationController = SandboxedModule.require modulePath, requires: "./AuthenticationManager": @AuthenticationManager = {} "../User/UserGetter" : @UserGetter = {} @@ -39,7 +40,6 @@ describe "AuthenticationController", -> @req = new MockRequest() @res = new MockResponse() @callback = @next = sinon.stub() - tk.freeze(Date.now()) afterEach -> tk.reset() diff --git a/services/web/test/unit/coffee/History/RestoreManagerTests.coffee b/services/web/test/unit/coffee/History/RestoreManagerTests.coffee index 37fe6752e7..012c720a2b 100644 --- a/services/web/test/unit/coffee/History/RestoreManagerTests.coffee +++ b/services/web/test/unit/coffee/History/RestoreManagerTests.coffee @@ -10,6 +10,7 @@ moment = require('moment') describe 'RestoreManager', -> beforeEach -> + tk.freeze Date.now() # freeze the time for these tests @RestoreManager = SandboxedModule.require modulePath, requires: '../../infrastructure/FileWriter': @FileWriter = {} '../Uploads/FileSystemImportManager': @FileSystemImportManager = {} @@ -22,7 +23,6 @@ describe 'RestoreManager', -> @project_id = 'mock-project-id' @version = 42 @callback = sinon.stub() - tk.freeze Date.now() # freeze the time for these tests afterEach -> tk.reset() diff --git a/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee b/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee index d951653310..c4f6aff4c1 100644 --- a/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee +++ b/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee @@ -116,6 +116,7 @@ describe "RecurlyWrapper", -> apiKey: 'nonsense' privateKey: 'private_nonsense' + tk.freeze Date.now() # freeze the time for these tests @RecurlyWrapper = RecurlyWrapper = SandboxedModule.require modulePath, requires: "settings-sharelatex": @settings "logger-sharelatex": @@ -124,10 +125,11 @@ describe "RecurlyWrapper", -> log: sinon.stub() "request": sinon.stub() - describe "sign", -> + after -> + tk.reset() + describe "sign", -> before (done) -> - tk.freeze Date.now() # freeze the time for these tests @RecurlyWrapper.sign({ subscription : plan_code : "gold" @@ -137,9 +139,6 @@ describe "RecurlyWrapper", -> done() ) - after -> - tk.reset() - it "should be signed correctly", -> signed = @signature.split("|")[0] query = @signature.split("|")[1] @@ -1061,4 +1060,4 @@ describe "RecurlyWrapper", -> @RecurlyWrapper.listAccountActiveSubscriptions @user_id, @callback it "should return an empty array of subscriptions", -> - @callback.calledWith(null, []).should.equal true \ No newline at end of file + @callback.calledWith(null, []).should.equal true From bfad95ae6184873a3fa81f9618084faf0dd44d77 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Jun 2018 13:59:27 +0100 Subject: [PATCH 112/135] File restore button needs to be smaller to fit the new toolbar height. --- services/web/app/views/project/editor/history/diffPanelV1.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/history/diffPanelV1.pug b/services/web/app/views/project/editor/history/diffPanelV1.pug index 30588adf46..369f3703b0 100644 --- a/services/web/app/views/project/editor/history/diffPanelV1.pug +++ b/services/web/app/views/project/editor/history/diffPanelV1.pug @@ -14,7 +14,7 @@ ) | in {{history.diff.pathname}} .toolbar-right(ng-if="permissions.write") - a.btn.btn-danger.btn-sm( + a.btn.btn-danger.btn-xs( href, ng-click="openRestoreDiffModal()" ) #{translate("restore_to_before_these_changes")} From 613c9193e3ed7dfc093f024621be87c254f317f4 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Fri, 25 May 2018 13:04:09 +0200 Subject: [PATCH 113/135] implement multi emails logic --- .../coffee/Features/User/UserGetter.coffee | 18 +++ .../coffee/Features/User/UserUpdater.coffee | 93 +++++++++++-- services/web/app/coffee/models/User.coffee | 4 + .../coffee/RegistrationTests.coffee | 9 ++ .../acceptance/coffee/SettingsTests.coffee | 28 ++++ .../acceptance/coffee/helpers/User.coffee | 49 ++++++- .../unit/coffee/User/UserGetterTests.coffee | 18 +++ .../unit/coffee/User/UserUpdaterTests.coffee | 126 ++++++++++++++++-- 8 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 services/web/test/acceptance/coffee/SettingsTests.coffee diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 201981625e..10a975613e 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -21,6 +21,10 @@ module.exports = UserGetter = db.users.findOne query, projection, callback + getUserEmail: (userId, callback = (error, email) ->) -> + @getUser userId, { email: 1 }, (error, user) -> + callback(error, user?.email) + getUserByMainEmail: (email, projection, callback = (error, user) ->) -> email = email.trim() if arguments.length == 2 @@ -28,6 +32,18 @@ module.exports = UserGetter = projection = {} db.users.findOne email: email, projection, callback + getUserByAnyEmail: (email, projection, callback = (error, user) ->) -> + email = email.trim() + if arguments.length == 2 + callback = projection + projection = {} + db.users.findOne 'emails.email': email, projection, (error, user) => + return callback(error, user) if error? or user? + + # While multiple emails are being rolled out, check for the main email as + # well + @getUserByMainEmail email, projection, callback + getUsers: (user_ids, projection, callback = (error, users) ->) -> try user_ids = user_ids.map (u) -> ObjectId(u.toString()) @@ -48,7 +64,9 @@ module.exports = UserGetter = [ 'getUser', + 'getUserEmail', 'getUserByMainEmail', + 'getUserByAnyEmail', 'getUsers', 'getUserOrUserStubById' ].map (method) -> diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index 174d37bc38..fa9ee24450 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -2,6 +2,7 @@ logger = require("logger-sharelatex") mongojs = require("../../infrastructure/mongojs") metrics = require("metrics-sharelatex") db = mongojs.db +async = require("async") ObjectId = mongojs.ObjectId UserGetter = require("./UserGetter") @@ -11,23 +12,89 @@ module.exports = UserUpdater = query = _id: ObjectId(query) else if query instanceof ObjectId query = _id: query + else if typeof query._id == "string" + query._id = ObjectId(query._id) db.users.update query, update, callback - changeEmailAddress: (user_id, newEmail, callback)-> - self = @ - logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user" - UserGetter.getUserByMainEmail newEmail, (error, user) -> - if user? - return callback({message:"alread_exists"}) - self.updateUser user_id.toString(), { - $set: { "email": newEmail}, - }, (err) -> - if err? - logger.err err:err, "problem updating users email" - return callback(err) + # + # DEPRECATED + # + # Change the user's main email address by adding a new email, switching the + # default email and removing the old email. Prefer manipulating multiple + # emails and the default rather than calling this method directly + # + changeEmailAddress: (userId, newEmail, callback)-> + logger.log userId: userId, newEmail: newEmail, "updaing email address of user" + + oldEmail = null + async.series [ + (cb) -> + UserGetter.getUserEmail userId, (error, email) -> + oldEmail = email + cb(error) + (cb) -> UserUpdater.addEmailAddress userId, newEmail, cb + (cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb + (cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb + ], callback + + + # Add a new email address for the user. Email cannot be already used by this + # or any other user + addEmailAddress: (userId, newEmail, callback) -> + @_ensureUniqueEmailAddress newEmail, (error) => + return callback(error) if error? + + update = $push: emails: email: newEmail, createdAt: new Date() + @updateUser userId, update, (error) -> + if error? + logger.err error: error, 'problem updating users emails' + return callback(error) callback() -metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger + # remove one of the user's email addresses. The email cannot be the user's + # default email address + removeEmailAddress: (userId, email, callback) -> + query = _id: userId, email: $ne: email + update = $pull: emails: email: email + @updateUser query, update, (error, res) -> + if error? + logger.err error:error, 'problem removing users email' + return callback(error) + if res.nMatched == 0 + return callback(new Error('Cannot remove default email')) + callback() + + + # set the default email address by setting the `email` attribute. The email + # must be one of the user's multiple emails (`emails` attribute) + setDefaultEmailAddress: (userId, email, callback) -> + query = _id: userId, 'emails.email': email + update = $set: email: email + @updateUser query, update, (error, res) -> + if error? + logger.err error:error, 'problem setting default emails' + return callback(error) + if res.nMatched == 0 + return callback(new Error('Default email does not belong to user')) + callback() + + + # check for duplicate email address. This is also enforced at the DB level + _ensureUniqueEmailAddress: (newEmail, callback) -> + UserGetter.getUserByAnyEmail newEmail, (error, user) -> + return callback(message: 'alread_exists') if user? + callback() + + +[ + 'updateUser' + 'changeEmailAddress' + 'setDefaultEmailAddress' + 'addEmailAddress' + 'removeEmailAddress' + '_ensureUniqueEmailAddress' +].map (method) -> + metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger) diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 009f582b2a..71982ba40b 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -8,6 +8,10 @@ ObjectId = Schema.ObjectId UserSchema = new Schema email : {type : String, default : ''} + emails: [{ + email: { type : String, default : '' }, + createdAt: { type : Date, default: () -> new Date() } + }], first_name : {type : String, default : ''} last_name : {type : String, default : ''} role : {type : String, default : ''} diff --git a/services/web/test/acceptance/coffee/RegistrationTests.coffee b/services/web/test/acceptance/coffee/RegistrationTests.coffee index 313722a71b..89d1bf3299 100644 --- a/services/web/test/acceptance/coffee/RegistrationTests.coffee +++ b/services/web/test/acceptance/coffee/RegistrationTests.coffee @@ -146,6 +146,15 @@ describe "LoginViaRegistration", -> describe "[Security] Trying to register/login as another user", -> + it 'should not allow sign in with secondary email', (done) -> + secondaryEmail = "acceptance-test-secondary@example.com" + @user1.addEmail secondaryEmail, (err) => + @user1.loginWith secondaryEmail, (err) => + expect(err?).to.equal false + @user1.isLoggedIn (err, isLoggedIn) -> + expect(isLoggedIn).to.equal false + done() + it 'should have user1 login', (done) -> @user1.login (err) -> expect(err?).to.equal false diff --git a/services/web/test/acceptance/coffee/SettingsTests.coffee b/services/web/test/acceptance/coffee/SettingsTests.coffee new file mode 100644 index 0000000000..bd2942e072 --- /dev/null +++ b/services/web/test/acceptance/coffee/SettingsTests.coffee @@ -0,0 +1,28 @@ +should = require('chai').should() +async = require("async") +User = require "./helpers/User" + +describe 'SettingsPage', -> + + before (done) -> + @user = new User() + async.series [ + @user.ensureUserExists.bind(@user) + @user.login.bind(@user) + @user.activateSudoMode.bind(@user) + ], done + + it 'load settigns page', (done) -> + @user.getUserSettingsPage (err, statusCode) -> + statusCode.should.equal 200 + done() + + it 'update main email address', (done) -> + newEmail = 'foo@bar.com' + @user.updateSettings email: newEmail, (error) => + should.not.exist error + @user.get (error, user) -> + user.email.should.equal newEmail + user.emails.length.should.equal 1 + user.emails[0].email.should.equal newEmail + done() diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 61c2e84a8c..f83780b535 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -3,13 +3,18 @@ _ = require("underscore") settings = require("settings-sharelatex") {db, ObjectId} = require("../../../../app/js/infrastructure/mongojs") UserModel = require("../../../../app/js/models/User").User +UserUpdater = require("../../../../app/js/Features/User/UserUpdater") AuthenticationManager = require("../../../../app/js/Features/Authentication/AuthenticationManager") count = 0 class User constructor: (options = {}) -> - @email = "acceptance-test-#{count}@example.com" + @emails = [ + email: "acceptance-test-#{count}@example.com" + createdAt: new Date() + ] + @email = @emails[0].email @password = "acceptance-test-#{count}-password" count++ @jar = request.jar() @@ -17,14 +22,20 @@ class User jar: @jar }) + get: (callback = (error, user)->) -> + db.users.findOne { _id: ObjectId(@_id) }, callback + login: (callback = (error) ->) -> + @loginWith(@email, callback) + + loginWith: (email, callback = (error) ->) -> @ensureUserExists (error) => return callback(error) if error? @getCsrfToken (error) => return callback(error) if error? @request.post { url: "/login" - json: { @email, @password } + json: { email, @password } }, callback ensureUserExists: (callback = (error) ->) -> @@ -34,11 +45,14 @@ class User return callback(error) if error? AuthenticationManager.setUserPassword user._id, @password, (error) => return callback(error) if error? - @id = user?._id?.toString() - @_id = user?._id?.toString() - @first_name = user?.first_name - @referal_id = user?.referal_id - callback(null, @password) + UserUpdater.updateUser user._id, $set: emails: @emails, (error) => + return callback(error) if error? + @id = user?._id?.toString() + @_id = user?._id?.toString() + @first_name = user?.first_name + @referal_id = user?.referal_id + + callback(null, @password) setFeatures: (features, callback = (error) ->) -> update = {} @@ -62,6 +76,10 @@ class User @_id = user?._id?.toString() callback() + addEmail: (email, callback = (error) ->) -> + @emails.push(email: email, createdAt: new Date()) + UserUpdater.addEmailAddress @id, email, callback + ensure_admin: (callback = (error) ->) -> db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback @@ -226,6 +244,23 @@ class User return callback(error) if error? callback(null, response.statusCode) + activateSudoMode: (callback = (error)->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + uri: '/confirm-password', + json: + password: @password + }, callback + + updateSettings: (newSettings, callback = (error, response, body) ->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: '/user/settings' + json: newSettings + }, callback + getProjectListPage: (callback=(error, statusCode)->) -> @getCsrfToken (error) => return callback(error) if error? diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index 2c32bd8250..cdc22772f3 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -56,3 +56,21 @@ describe "UserGetter", -> @findOne.called.should.equal true @findOne.calledWith(email: email).should.equal true done() + + describe "getUserByAnyEmail", -> + it "query user for any email", (done)-> + email = 'hello@world.com' + projection = emails: 1 + @UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) => + @findOne.calledWith('emails.email': email, projection).should.equal true + user.should.deep.equal @fakeUser + done() + + it "checks main email as well", (done)-> + @findOne.callsArgWith(2, null, null) + email = 'hello@world.com' + projection = emails: 1 + @UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) => + @findOne.calledTwice.should.equal true + @findOne.calledWith(email: email, projection).should.equal true + done() diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index 202a4f3f1a..b952a688ae 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -15,35 +15,135 @@ describe "UserUpdater", -> db:{} ObjectId:(id)-> return id @UserGetter = - getUserByMainEmail: sinon.stub() + getUserEmail: sinon.stub() + getUserByAnyEmail: sinon.stub() + @logger = err: sinon.stub(), log: -> @UserUpdater = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings - "logger-sharelatex": log:-> + "logger-sharelatex": @logger "./UserGetter": @UserGetter "../../infrastructure/mongojs":@mongojs "metrics-sharelatex": timeAsyncMethod: sinon.stub() @stubbedUser = + _id: "3131231" name:"bob" email:"hello@world.com" - @user_id = "3131231" @newEmail = "bob@bob.com" - describe "changeEmailAddress", -> + describe 'changeEmailAddress', -> beforeEach -> - @UserUpdater.updateUser = sinon.stub().callsArgWith(2) + @UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email) + @UserUpdater.addEmailAddress = sinon.stub().callsArgWith(2) + @UserUpdater.setDefaultEmailAddress = sinon.stub().callsArgWith(2) + @UserUpdater.removeEmailAddress = sinon.stub().callsArgWith(2) - it "should check if the new email already has an account", (done)-> - @UserGetter.getUserByMainEmail.callsArgWith(1, null, @stubbedUser) - @UserUpdater.changeEmailAddress @user_id, @stubbedUser.email, (err)=> - @UserUpdater.updateUser.called.should.equal false + it 'change email', (done)-> + @UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.addEmailAddress.calledWith( + @stubbedUser._id, @newEmail + ).should.equal true + @UserUpdater.setDefaultEmailAddress.calledWith( + @stubbedUser._id, @newEmail + ).should.equal true + @UserUpdater.removeEmailAddress.calledWith( + @stubbedUser._id, @stubbedUser.email + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.removeEmailAddress.callsArgWith(2, new Error('nope')) + @UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + describe 'addEmailAddress', -> + beforeEach -> + @UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) + + it 'add email', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null) + + @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> + @UserUpdater._ensureUniqueEmailAddress.called.should.equal true + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + @stubbedUser._id, + $push: { emails: { email: @newEmail, createdAt: sinon.match.date } } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> + @logger.err.called.should.equal true + should.exist(err) + done() + + describe 'removeEmailAddress', -> + it 'remove email', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + { _id: @stubbedUser._id, email: { $ne: @newEmail } }, + $pull: { emails: { email: @newEmail } } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + it 'handle missed update', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + describe 'setDefaultEmailAddress', -> + it 'set default', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + { _id: @stubbedUser._id, 'emails.email': @newEmail }, + $set: { email: @newEmail } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + it 'handle missed update', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> should.exist(err) done() - it "should set the users password", (done)-> - @UserGetter.getUserByMainEmail.callsArgWith(1, null) - @UserUpdater.changeEmailAddress @user_id, @newEmail, (err)=> - @UserUpdater.updateUser.calledWith(@user_id, $set: { "email": @newEmail}).should.equal true + describe '_ensureUniqueEmailAddress', -> + it 'should return error if existing user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.exist(err) done() + it 'should return null if no user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.not.exist(err) + done() From 1f6fcafce6636a950b6ebc588788a99767298a2b Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 6 Jun 2018 11:11:57 +0200 Subject: [PATCH 114/135] remove default emails attribute on user model --- services/web/app/coffee/models/User.coffee | 4 ---- 1 file changed, 4 deletions(-) diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 71982ba40b..009f582b2a 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -8,10 +8,6 @@ ObjectId = Schema.ObjectId UserSchema = new Schema email : {type : String, default : ''} - emails: [{ - email: { type : String, default : '' }, - createdAt: { type : Date, default: () -> new Date() } - }], first_name : {type : String, default : ''} last_name : {type : String, default : ''} role : {type : String, default : ''} From c5530163f54893e3d6ca9cf8aae29ac9b1af3c88 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Mon, 28 May 2018 16:08:37 +0200 Subject: [PATCH 115/135] add emails attribute on user creation --- .../coffee/Features/User/UserCreator.coffee | 4 +++ .../coffee/Features/User/UserGetter.coffee | 9 ++++++- .../User/UserRegistrationHandler.coffee | 2 +- .../coffee/Features/User/UserUpdater.coffee | 11 +------- services/web/app/coffee/models/User.coffee | 4 +++ .../coffee/RegistrationTests.coffee | 14 ++++++++++ .../acceptance/coffee/helpers/User.coffee | 27 +++++++++++++++---- .../unit/coffee/User/UserCreatorTests.coffee | 8 ++++++ .../unit/coffee/User/UserGetterTests.coffee | 17 ++++++++++++ .../User/UserRegistrationHandlerTests.coffee | 8 +++--- .../unit/coffee/User/UserUpdaterTests.coffee | 17 +++--------- 11 files changed, 86 insertions(+), 35 deletions(-) diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index 0a0cc8641e..4b4dd30ae2 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -18,6 +18,10 @@ module.exports = UserCreator = user.ace.syntaxValidation = true user.featureSwitches?.pdfng = true + user.emails = [ + email: user.email + createdAt: new Date() + ] user.save (err)-> callback(err, user) diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 10a975613e..2d55d0e432 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -62,12 +62,19 @@ module.exports = UserGetter = return callback(null, user) if user? db.userstubs.findOne query, projection, callback + # check for duplicate email address. This is also enforced at the DB level + ensureUniqueEmailAddress: (newEmail, callback) -> + @getUserByAnyEmail newEmail, (error, user) -> + return callback(message: 'alread_exists') if user? + callback(error) + [ 'getUser', 'getUserEmail', 'getUserByMainEmail', 'getUserByAnyEmail', 'getUsers', - 'getUserOrUserStubById' + 'getUserOrUserStubById', + 'ensureUniqueEmailAddress', ].map (method) -> metrics.timeAsyncMethod UserGetter, method, 'mongo.UserGetter', logger diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index fab438ffa6..4a42d7f5fa 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -48,7 +48,7 @@ module.exports = UserRegistrationHandler = if !requestIsValid return callback(new Error("request is not valid")) userDetails.email = userDetails.email?.trim()?.toLowerCase() - UserGetter.getUserByMainEmail userDetails.email, (err, user) => + UserGetter.getUserByAnyEmail userDetails.email, (err, user) => if err? return callback err if user?.holdingAccount == false diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index fa9ee24450..22b31239bd 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -43,7 +43,7 @@ module.exports = UserUpdater = # Add a new email address for the user. Email cannot be already used by this # or any other user addEmailAddress: (userId, newEmail, callback) -> - @_ensureUniqueEmailAddress newEmail, (error) => + UserGetter.ensureUniqueEmailAddress newEmail, (error) => return callback(error) if error? update = $push: emails: email: newEmail, createdAt: new Date() @@ -81,20 +81,11 @@ module.exports = UserUpdater = return callback(new Error('Default email does not belong to user')) callback() - - # check for duplicate email address. This is also enforced at the DB level - _ensureUniqueEmailAddress: (newEmail, callback) -> - UserGetter.getUserByAnyEmail newEmail, (error, user) -> - return callback(message: 'alread_exists') if user? - callback() - - [ 'updateUser' 'changeEmailAddress' 'setDefaultEmailAddress' 'addEmailAddress' 'removeEmailAddress' - '_ensureUniqueEmailAddress' ].map (method) -> metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger) diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 009f582b2a..71982ba40b 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -8,6 +8,10 @@ ObjectId = Schema.ObjectId UserSchema = new Schema email : {type : String, default : ''} + emails: [{ + email: { type : String, default : '' }, + createdAt: { type : Date, default: () -> new Date() } + }], first_name : {type : String, default : ''} last_name : {type : String, default : ''} role : {type : String, default : ''} diff --git a/services/web/test/acceptance/coffee/RegistrationTests.coffee b/services/web/test/acceptance/coffee/RegistrationTests.coffee index 89d1bf3299..8bcf8c3685 100644 --- a/services/web/test/acceptance/coffee/RegistrationTests.coffee +++ b/services/web/test/acceptance/coffee/RegistrationTests.coffee @@ -128,6 +128,20 @@ describe "CSRF protection", -> expect(response.statusCode).to.equal 403 done() +describe "Register", -> + before -> + @user = new User() + + it 'Set emails attribute', (done) -> + @user.register (error, user) => + expect(error).to.not.exist + user.email.should.equal @user.email + user.emails.should.exist + user.emails.should.be.a 'array' + user.emails.length.should.equal 1 + user.emails[0].email.should.equal @user.email + done() + describe "LoginViaRegistration", -> before (done) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index f83780b535..793ac6cd9f 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -22,9 +22,30 @@ class User jar: @jar }) + setExtraAttributes: (user) -> + throw new Error("User does not exist") unless user?._id? + @id = user._id.toString() + @_id = user._id.toString() + @first_name = user.first_name + @referal_id = user.referal_id + get: (callback = (error, user)->) -> db.users.findOne { _id: ObjectId(@_id) }, callback + register: (callback = (error, user) ->) -> + return callback(new Error('User already registered')) if @_id? + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: '/register' + json: { @email, @password } + }, (error, response, body) => + return callback(error) if error? + db.users.findOne { email: @email }, (error, user) => + return callback(error) if error? + @setExtraAttributes user + callback(null, user) + login: (callback = (error) ->) -> @loginWith(@email, callback) @@ -47,11 +68,7 @@ class User return callback(error) if error? UserUpdater.updateUser user._id, $set: emails: @emails, (error) => return callback(error) if error? - @id = user?._id?.toString() - @_id = user?._id?.toString() - @first_name = user?.first_name - @referal_id = user?.referal_id - + @setExtraAttributes user callback(null, @password) setFeatures: (features, callback = (error) ->) -> diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index cc2b1ec150..3764c8a765 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -70,3 +70,11 @@ describe "UserCreator", -> assert.equal user.holdingAccount, true assert.equal user.last_name, "lastNammmmeee" done() + + it "should set emails attribute", (done)-> + @UserCreator.createNewUser email: @email, (err, user)=> + user.email.should.equal @email + user.emails.length.should.equal 1 + user.emails[0].email.should.equal @email + user.emails[0].createdAt.should.be.a 'date' + done() diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index cdc22772f3..9ce4c843ff 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -74,3 +74,20 @@ describe "UserGetter", -> @findOne.calledTwice.should.equal true @findOne.calledWith(email: email, projection).should.equal true done() + + describe 'ensureUniqueEmailAddress', -> + beforeEach -> + @UserGetter.getUserByAnyEmail = sinon.stub() + + it 'should return error if existing user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @fakeUser) + @UserGetter.ensureUniqueEmailAddress @newEmail, (err)=> + should.exist(err) + err.message.should.equal 'alread_exists' + done() + + it 'should return null if no user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1) + @UserGetter.ensureUniqueEmailAddress @newEmail, (err)=> + should.not.exist(err) + done() diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index 9411059022..e738947171 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -14,7 +14,7 @@ describe "UserRegistrationHandler", -> @User = update: sinon.stub().callsArgWith(2) @UserGetter = - getUserByMainEmail: sinon.stub() + getUserByAnyEmail: sinon.stub() @UserCreator = createNewUser:sinon.stub().callsArgWith(1, null, @user) @AuthenticationManager = @@ -72,7 +72,7 @@ describe "UserRegistrationHandler", -> beforeEach -> @user.holdingAccount = true @handler._registrationRequestIsValid = sinon.stub().returns true - @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user) it "should not create a new user if there is a holding account there", (done)-> @handler.registerNewUser @passingRequest, (err)=> @@ -96,7 +96,7 @@ describe "UserRegistrationHandler", -> done() it "should return email registered in the error if there is a non holdingAccount there", (done)-> - @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false}) + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user = {holdingAccount:false}) @handler.registerNewUser @passingRequest, (err, user)=> err.should.deep.equal new Error("EmailAlreadyRegistered") user.should.deep.equal @user @@ -105,7 +105,7 @@ describe "UserRegistrationHandler", -> describe "validRequest", -> beforeEach -> @handler._registrationRequestIsValid = sinon.stub().returns true - @UserGetter.getUserByMainEmail.callsArgWith 1 + @UserGetter.getUserByAnyEmail.callsArgWith 1 it "should create a new user", (done)-> @handler.registerNewUser @passingRequest, (err)=> diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index b952a688ae..7b3be3df2b 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -17,6 +17,7 @@ describe "UserUpdater", -> @UserGetter = getUserEmail: sinon.stub() getUserByAnyEmail: sinon.stub() + ensureUniqueEmailAddress: sinon.stub() @logger = err: sinon.stub(), log: -> @UserUpdater = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings @@ -60,13 +61,13 @@ describe "UserUpdater", -> describe 'addEmailAddress', -> beforeEach -> - @UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) + @UserGetter.ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) it 'add email', (done)-> @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null) @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> - @UserUpdater._ensureUniqueEmailAddress.called.should.equal true + @UserGetter.ensureUniqueEmailAddress.called.should.equal true should.not.exist(err) @UserUpdater.updateUser.calledWith( @stubbedUser._id, @@ -135,15 +136,3 @@ describe "UserUpdater", -> done() - describe '_ensureUniqueEmailAddress', -> - it 'should return error if existing user is found', (done)-> - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser) - @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> - should.exist(err) - done() - - it 'should return null if no user is found', (done)-> - @UserGetter.getUserByAnyEmail.callsArgWith(1) - @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> - should.not.exist(err) - done() From 813289f5de740128b44294fdf0699bfb758abaee Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Mon, 28 May 2018 16:09:22 +0200 Subject: [PATCH 116/135] use multiple emails when relevant --- .../CollaboratorsInviteController.coffee | 2 +- .../CollaboratorsInviteHandler.coffee | 2 +- .../SubscriptionGroupHandler.coffee | 2 +- .../CollaboratorsInviteControllerTests.coffee | 12 ++++++------ .../CollaboratorsInviteHandlerTests.coffee | 18 +++++++++--------- .../SubscriptionGroupHandlerTests.coffee | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee index f74a144bac..8b1b481994 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController = _checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) -> if Settings.restrictInvitesToExistingAccounts == true logger.log {email}, "checking if user exists with this email" - UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) -> + UserGetter.getUserByAnyEmail email, {_id: 1}, (err, user) -> return callback(err) if err? userExists = user? and user?._id? callback(null, userExists) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee index b511f56e53..58121dae2c 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler = _trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) -> email = invite.email - UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) -> + UserGetter.getUserByAnyEmail email, {_id: 1}, (err, existingUser) -> if err? logger.err {projectId, email}, "error checking if user exists" return callback(err) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index d6ce0dde59..9463538250 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -21,7 +21,7 @@ module.exports = SubscriptionGroupHandler = if limitReached logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) - UserGetter.getUserByMainEmail newEmail, (err, user)-> + UserGetter.getUserByAnyEmail newEmail, (err, user)-> return callback(err) if err? if user? SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee index 4b57ff3697..affef2dc31 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -25,7 +25,7 @@ describe "CollaboratorsInviteController", -> @LimitationsManager = {} @UserGetter = - getUserByMainEmail: sinon.stub() + getUserByAnyEmail: sinon.stub() getUser: sinon.stub() @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: @@ -716,7 +716,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = {_id: ObjectId().toString()} - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `true`', (done) -> @call (err, shouldAllow) => @@ -728,7 +728,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = null - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `false`', (done) -> @call (err, shouldAllow) => @@ -738,15 +738,15 @@ describe "CollaboratorsInviteController", -> it 'should have called getUser', (done) -> @call (err, shouldAllow) => - @UserGetter.getUserByMainEmail.callCount.should.equal 1 - @UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true + @UserGetter.getUserByAnyEmail.callCount.should.equal 1 + @UserGetter.getUserByAnyEmail.calledWith(@email, {_id: 1}).should.equal true done() describe 'when getUser produces an error', -> beforeEach -> @user = null - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should callback with an error', (done) -> @call (err, shouldAllow) => diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee index 58b373f61f..edad39a637 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", -> _id: ObjectId() first_name: "jim" @existingUser = {_id: ObjectId()} - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @existingUser) @fakeProject = _id: @project_id name: "some project" @@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByMainEmail.callCount.should.equal 1 - @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByAnyEmail.callCount.should.equal 1 + @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true done() it 'should call getProject', (done) -> @@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the user does not exist', -> beforeEach -> - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, null) it 'should not produce an error', (done) -> @call (err) => @@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByMainEmail.callCount.should.equal 1 - @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByAnyEmail.callCount.should.equal 1 + @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> @@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the getUser produces an error', -> beforeEach -> - @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err) => @@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByMainEmail.callCount.should.equal 1 - @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByAnyEmail.callCount.should.equal 1 + @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index bca9ac7600..9a70ca0411 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -32,7 +32,7 @@ describe "SubscriptionGroupHandler", -> @UserGetter = getUser: sinon.stub() - getUserByMainEmail: sinon.stub() + getUserByAnyEmail: sinon.stub() @LimitationsManager = hasGroupMembersLimitReached: sinon.stub() @@ -71,11 +71,11 @@ describe "SubscriptionGroupHandler", -> describe "addUserToGroup", -> beforeEach -> @LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription) - @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user) it "should find the user", (done)-> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> - @UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true + @UserGetter.getUserByAnyEmail.calledWith(@newEmail).should.equal true done() it "should add the user to the group", (done)-> @@ -102,7 +102,7 @@ describe "SubscriptionGroupHandler", -> done() it "should add an email invite if no user is found", (done) -> - @UserGetter.getUserByMainEmail.callsArgWith(1, null, null) + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, null) @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true done() From e4da74825768bf20ae23f9de85453c8ac10d1551 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 6 Jun 2018 14:52:09 +0200 Subject: [PATCH 117/135] add filter to query on emails attribute --- services/web/app/coffee/Features/User/UserGetter.coffee | 3 ++- services/web/test/unit/coffee/User/UserGetterTests.coffee | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 10a975613e..3160258b24 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -37,7 +37,8 @@ module.exports = UserGetter = if arguments.length == 2 callback = projection projection = {} - db.users.findOne 'emails.email': email, projection, (error, user) => + query = emails: { $exists: true }, 'emails.email': email + db.users.findOne query, projection, (error, user) => return callback(error, user) if error? or user? # While multiple emails are being rolled out, check for the main email as diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index cdc22772f3..b25bab87a7 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -60,9 +60,12 @@ describe "UserGetter", -> describe "getUserByAnyEmail", -> it "query user for any email", (done)-> email = 'hello@world.com' + expectedQuery = + emails: { $exists: true } + 'emails.email': email projection = emails: 1 @UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) => - @findOne.calledWith('emails.email': email, projection).should.equal true + @findOne.calledWith(expectedQuery, projection).should.equal true user.should.deep.equal @fakeUser done() From 7726314e7c8eed9d6e3620f81ca66448b7ec4184 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 6 Jun 2018 15:45:12 +0200 Subject: [PATCH 118/135] add test to explicitely check filter --- services/web/test/unit/coffee/User/UserGetterTests.coffee | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index b25bab87a7..7fb14a7f7d 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -69,6 +69,14 @@ describe "UserGetter", -> user.should.deep.equal @fakeUser done() + it "query contains $exists:true so partial index is used", (done)-> + expectedQuery = + emails: { $exists: true } + 'emails.email': '' + @UserGetter.getUserByAnyEmail '', {}, (error, user) => + @findOne.calledWith(expectedQuery, {}).should.equal true + done() + it "checks main email as well", (done)-> @findOne.callsArgWith(2, null, null) email = 'hello@world.com' From 3cb499a3c2aac549e0dc04f7949083f5519761c8 Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 6 Jun 2018 15:46:41 +0200 Subject: [PATCH 119/135] add comment --- services/web/app/coffee/Features/User/UserGetter.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 3160258b24..2e6d4eada6 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -37,6 +37,7 @@ module.exports = UserGetter = if arguments.length == 2 callback = projection projection = {} + # $exists: true MUST be set to use the partial index query = emails: { $exists: true }, 'emails.email': email db.users.findOne query, projection, (error, user) => return callback(error, user) if error? or user? From 8f71b104c52d2e6f9e759e14898c22c26c62c1ab Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 6 Jun 2018 16:59:13 +0100 Subject: [PATCH 120/135] Fix bug where unowned project would show archive quick action instead of leave If the user does not own the project, the project can only be left, not archived. Previously the quick action button was only showing the archive icon but clicking the button would correctly leave the project. This is confusing, so this commit corrects to show the leave icon for projects not owned by the current user --- services/web/app/views/project/list/item.pug | 12 ++++++++++-- .../coffee/main/project-list/project-list.coffee | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index bc37683489..5a9cb2138d 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -64,13 +64,21 @@ if settings.overleaf ) i.icon.fa.fa-cloud-download button.btn.btn-link.action-btn( - ng-if="!project.archived" + ng-if="!project.archived && isOwner()" tooltip=translate('archive'), tooltip-placement="top", tooltip-append-to-body="true", - ng-click="archive($event)" + ng-click="archiveOrLeave($event)" ) i.icon.fa.fa-inbox + button.btn.btn-link.action-btn( + ng-if="!project.archived && !isOwner()" + tooltip=translate('leave'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="archiveOrLeave($event)" + ) + i.icon.fa.fa-sign-out button.btn.btn-link.action-btn( ng-if="project.archived" tooltip=translate('unarchive'), diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 6696243905..841ccd6c2d 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -490,6 +490,9 @@ define [ else return "None" + $scope.isOwner = () -> + window.user_id == $scope.project.owner._id + $scope.$watch "project.selected", (value) -> if value? $scope.updateSelectedProjects() @@ -502,7 +505,7 @@ define [ e.stopPropagation() $scope.downloadProjectsById([$scope.project.id]) - $scope.archive = (e) -> + $scope.archiveOrLeave = (e) -> e.stopPropagation() $scope.archiveOrLeaveProjects([$scope.project]) From 0900559579e7b72fde509e59584acf94376b8ab1 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 6 Jun 2018 17:19:12 +0100 Subject: [PATCH 121/135] Fix blurrly autocomplete highlight on Chrome The blurrly text shadow is back on Chrome. I suspect it maybe intended, not a bug, so I've fixed it for all versions of Chrome. I've replaced with font-weight: bold, which visually has the same appearance --- services/web/public/coffee/ide.coffee | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 6c5b1d920e..9f910d857e 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -213,11 +213,10 @@ define [ try chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null; browserIsChrome61or62 = ( - chromeVersion? && - (chromeVersion == 61 || chromeVersion == 62) + chromeVersion? ) if browserIsChrome61or62 - document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; }", 1) + document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1) catch err console.error err From 458650d4566e2182617384c3d8e14f333a040c0b Mon Sep 17 00:00:00 2001 From: Tim Alby Date: Wed, 6 Jun 2018 15:30:33 +0200 Subject: [PATCH 122/135] create script to backfill secondary emails --- services/web/scripts/add_multiple_emails.js | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 services/web/scripts/add_multiple_emails.js diff --git a/services/web/scripts/add_multiple_emails.js b/services/web/scripts/add_multiple_emails.js new file mode 100644 index 0000000000..8da2d57dac --- /dev/null +++ b/services/web/scripts/add_multiple_emails.js @@ -0,0 +1,61 @@ +const mongojs = require('../app/js/infrastructure/mongojs') +const { db } = mongojs +const async = require('async') +const minilist = require('minimist') + +const updateUser = function (user, callback) { + console.log(`Updating user ${user._id}`) + const update = { + $set: { + emails: [{ + email: user.email, + createdAt: new Date() + }] + } + } + db.users.update({_id: user._id}, update, callback) +} + +const updateUsers = (users, callback) => + async.eachLimit(users, ASYNC_LIMIT, updateUser, function (error) { + if (error) { + callback(error) + return + } + counter += users.length + console.log(`${counter} users updated`) + loopForUsers(callback) + }) + +var loopForUsers = callback => + db.users.find( + { emails: {$exists: false} }, + { email: 1 } + ).limit(FETCH_LIMIT, function (error, users) { + if (error) { + callback(error) + return + } + if (users.length === 0) { + console.log(`DONE (${counter} users updated)`) + return callback() + } + updateUsers(users, callback) + }) + +var counter = 0 +var run = () => + loopForUsers(function (error) { + if (error) { throw error } + process.exit() + }) + +let FETCH_LIMIT, ASYNC_LIMIT +var setup = function () { + let args = minilist(process.argv.slice(2)) + FETCH_LIMIT = (args.fetch) ? args.fetch : 100 + ASYNC_LIMIT = (args.async) ? args.async : 10 +} + +setup() +run() From 4a1c2cf0e07af4804a25908f33b4624ba5f92794 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 6 Jun 2018 13:44:02 +0100 Subject: [PATCH 123/135] Set cloned project's owner to current user Fixes a bug where cloning a project then selecting to delete it, the wrong button for deletion is shown (leave instead of archive/delete). This is because we are using the owner object (which was undefined after cloning) to determine which button to show --- .../web/public/coffee/main/project-list/project-list.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 6696243905..6e40bb04c1 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -320,6 +320,9 @@ define [ name: cloneName id: data.project_id accessLevel: "owner" + owner: { + _id: user_id + } # TODO: Check access level if correct after adding it in # to the rest of the app } From c684fc3383386f1edc61e65b4a97e2610f71c8c1 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Thu, 7 Jun 2018 12:57:01 +0100 Subject: [PATCH 124/135] fix first/last name check bug --- .../web/app/coffee/Features/Exports/ExportsController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index 393ca95fdb..40cb7bb507 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -13,7 +13,7 @@ module.exports = user_id: user_id } - if req.body && req.body.firstName && req.body.firstName + if req.body && req.body.firstName && req.body.lastName export_params.first_name = req.body.firstName.trim() export_params.last_name = req.body.lastName.trim() From bfaa6d8dcc04c10d2c7c9fb88dc9be283cc3b058 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 29 May 2018 11:41:08 +0100 Subject: [PATCH 125/135] Improve styling of buttons --- .../stylesheets/app/editor/toolbar.less | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index a73f46abc3..f4521c1600 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -184,8 +184,12 @@ } } +/************************************** + Toggle Switch +***************************************/ + .toggle-wrapper { - width: 200px; + min-width: 200px; height: 24px; } @@ -241,3 +245,22 @@ transform: translate(100%); border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0; } + +/************************************** + Formatting buttons +***************************************/ +.formatting-btn { + padding: 0; + min-width: 32px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + border: none; + border-left: 1px solid #ccc; + + &:last-child { + border-right: 1px solid #ccc; + } +} From 7384cfba1a411149e0c550bfa5f2191823477329 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 29 May 2018 16:56:38 +0100 Subject: [PATCH 126/135] Style wrapped buttons so the toolbar can be resized --- services/web/public/stylesheets/app/editor.less | 1 + .../web/public/stylesheets/app/editor/toolbar.less | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 0e3d48cf87..af3063cf22 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -92,6 +92,7 @@ .toolbar-editor { height: @editor-toolbar-height; background-color: @editor-toolbar-bg; + overflow: hidden; } .loading-screen { diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index f4521c1600..e5601fe4d3 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -249,9 +249,14 @@ /************************************** Formatting buttons ***************************************/ +.button-measurer-wrapper { + display: flex; +} + .formatting-btn { padding: 0; min-width: 32px; + width: 32px; height: 100%; display: flex; align-items: center; @@ -259,8 +264,14 @@ box-shadow: none; border: none; border-left: 1px solid #ccc; + border-radius: 0; &:last-child { border-right: 1px solid #ccc; } } + +.formatting-btn-icon { + font-style: normal; + line-height: 1.5; +} From aaf5da877e07ff672cd74014e0561c2ad4289155 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 31 May 2018 13:31:24 +0100 Subject: [PATCH 127/135] Style different buttons --- .../stylesheets/app/editor/toolbar.less | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index e5601fe4d3..008536152b 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -249,14 +249,15 @@ /************************************** Formatting buttons ***************************************/ -.button-measurer-wrapper { +formatting-buttons { display: flex; + width: 100%; + overflow: hidden; } .formatting-btn { padding: 0; - min-width: 32px; - width: 32px; + margin: 3px 0; height: 100%; display: flex; align-items: center; @@ -265,12 +266,23 @@ border: none; border-left: 1px solid #ccc; border-radius: 0; +} - &:last-child { +.formatting-btn-with-icon { + min-width: 32px; + width: 32px; + + &:nth-child(-1) { border-right: 1px solid #ccc; } } +.formatting-btn-overflowed { + margin-left: auto; + padding-left: 5px; + padding-right: 5px; +} + .formatting-btn-icon { font-style: normal; line-height: 1.5; From c5f62d3aa3ad341b90f63e0a0b2377832382a296 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 31 May 2018 16:10:40 +0100 Subject: [PATCH 128/135] Style dropdown & clean up naming --- .../stylesheets/app/editor/toolbar.less | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 008536152b..55303a64c6 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -257,7 +257,6 @@ formatting-buttons { .formatting-btn { padding: 0; - margin: 3px 0; height: 100%; display: flex; align-items: center; @@ -266,24 +265,35 @@ formatting-buttons { border: none; border-left: 1px solid #ccc; border-radius: 0; -} -.formatting-btn-with-icon { - min-width: 32px; - width: 32px; + &--icon { + min-width: 32px; + width: 32px; - &:nth-child(-1) { - border-right: 1px solid #ccc; + &:last-of-type { + border-right: 1px solid @formatting-btn-border; + } + } + + &--more { + padding-left: 9px; + padding-right: 9px; } } -.formatting-btn-overflowed { - margin-left: auto; - padding-left: 5px; - padding-right: 5px; -} - -.formatting-btn-icon { +.formatting-icon { font-style: normal; line-height: 1.5; } + +.formatting-more { + margin-left: auto; +} + +.formatting-menu { + min-width: auto; + + .formatting-menu-item { + float: left; + } +} From 611a6f9c0bb18dcb93dd6a602886a6cd191ac888 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 31 May 2018 16:26:40 +0100 Subject: [PATCH 129/135] Reduce width of overflowed button menu --- services/web/public/stylesheets/app/editor/toolbar.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 55303a64c6..d4162aeb5a 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -292,8 +292,13 @@ formatting-buttons { .formatting-menu { min-width: auto; + max-width: 130px; .formatting-menu-item { float: left; + + &:nth-of-type(4n + 1) .formatting-btn { + border-left: none; + } } } From 12d7eb8a46f212853fc1d5efc883ab84aa9f4491 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 31 May 2018 17:47:55 +0100 Subject: [PATCH 130/135] Adjust styling to work with wrapper --- services/web/public/stylesheets/app/editor/toolbar.less | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index d4162aeb5a..f1faddb7a9 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -249,12 +249,15 @@ /************************************** Formatting buttons ***************************************/ -formatting-buttons { - display: flex; +.formatting-buttons { width: 100%; overflow: hidden; } +.formatting-buttons-wrapper { + display: flex; +} + .formatting-btn { padding: 0; height: 100%; From ea18d606c41bec4b2be644598481377c352019ae Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 1 Jun 2018 13:19:00 +0100 Subject: [PATCH 131/135] Nicer v2 styles --- .../web/public/stylesheets/app/editor/toolbar.less | 13 ++++++++++++- .../public/stylesheets/core/_common-variables.less | 6 ++++++ .../web/public/stylesheets/core/ol-variables.less | 6 ++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index f1faddb7a9..55e581c3d6 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -259,6 +259,8 @@ } .formatting-btn { + color: @formatting-btn-color; + background-color: @formatting-btn-bg; padding: 0; height: 100%; display: flex; @@ -266,9 +268,13 @@ justify-content: center; box-shadow: none; border: none; - border-left: 1px solid #ccc; + border-left: 1px solid @formatting-btn-border; border-radius: 0; + &:hover { + color: @formatting-btn-color; + } + &--icon { min-width: 32px; width: 32px; @@ -296,10 +302,15 @@ .formatting-menu { min-width: auto; max-width: 130px; + background-color: @formatting-menu-bg; .formatting-menu-item { float: left; + .formatting-btn { + border-right: none; + } + &:nth-of-type(4n + 1) .formatting-btn { border-left: none; } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 033ce9c1bf..5d08d9f95d 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -941,6 +941,12 @@ @toggle-switch-bg : @gray-lightest; @toggle-switch-highlight-color : @brand-primary; +// Formatting buttons +@formatting-btn-color : @btn-default-color; +@formatting-btn-bg : @btn-default-bg; +@formatting-btn-border : @btn-default-border; +@formatting-menu-bg : @btn-default-bg; + // Chat @chat-bg : transparent; @chat-message-color : @text-color; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 3d3d4468fd..114d07a5b1 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -244,6 +244,12 @@ @toggle-switch-radius-left : @btn-border-radius-base 0 0 @btn-border-radius-base; @toggle-switch-radius-right : 0 @btn-border-radius-base @btn-border-radius-base 0; +// Formatting buttons +@formatting-btn-color : #FFF; +@formatting-btn-bg : @ol-blue-gray-5; +@formatting-btn-border : @ol-blue-gray-4; +@formatting-menu-bg : @ol-blue-gray-5; + // Chat @chat-bg : @ol-blue-gray-5; @chat-message-color : #FFF; From 9ae92dbeef614436f3ecc21544577b318d87740b Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 1 Jun 2018 13:45:47 +0100 Subject: [PATCH 132/135] Small icon & math icon styling --- services/web/public/stylesheets/app/editor/toolbar.less | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 55e581c3d6..2f34e7a258 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -293,6 +293,15 @@ .formatting-icon { font-style: normal; line-height: 1.5; + + &--small { + font-size: small; + line-height: 1.9; + } + + &--serif { + font-family: @font-family-serif; + } } .formatting-more { From ad13eccfa71163714a1b4c07fc0ac1b024ba8d73 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 1 Jun 2018 15:09:15 +0100 Subject: [PATCH 133/135] Flatten rules for readability --- .../stylesheets/app/editor/toolbar.less | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 2f34e7a258..45a0384381 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -274,34 +274,34 @@ &:hover { color: @formatting-btn-color; } +} - &--icon { - min-width: 32px; - width: 32px; +.formatting-btn--icon { + min-width: 32px; + width: 32px; +} - &:last-of-type { - border-right: 1px solid @formatting-btn-border; - } - } +.formatting-btn--icon:last-of-type { + border-right: 1px solid @formatting-btn-border; +} - &--more { - padding-left: 9px; - padding-right: 9px; - } +.formatting-btn--more { + padding-left: 9px; + padding-right: 9px; } .formatting-icon { font-style: normal; line-height: 1.5; +} - &--small { - font-size: small; - line-height: 1.9; - } +.formatting-icon--small { + font-size: small; + line-height: 1.9; +} - &--serif { - font-family: @font-family-serif; - } +.formatting-icon--serif { + font-family: @font-family-serif; } .formatting-more { @@ -312,16 +312,17 @@ min-width: auto; max-width: 130px; background-color: @formatting-menu-bg; - - .formatting-menu-item { - float: left; - - .formatting-btn { - border-right: none; - } - - &:nth-of-type(4n + 1) .formatting-btn { - border-left: none; - } - } +} + +.formatting-menu-item { + float: left; +} + +.formatting-menu-item > .formatting-btn { + border-right: none; +} + +// Disable border on left-most icon in menu +.formatting-menu-item:nth-of-type(4n + 1) > .formatting-btn { + border-left: none; } From c4c94419954ea6be10540a651742a7b28203cdab Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 5 Jun 2018 14:55:32 +0100 Subject: [PATCH 134/135] Adjust caret down --- services/web/public/stylesheets/app/editor/toolbar.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 45a0384381..4006b56a7b 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -288,6 +288,10 @@ .formatting-btn--more { padding-left: 9px; padding-right: 9px; + + .caret { + margin-top: 1px; + } } .formatting-icon { From 7cdcd725fd6cce231b933acba313ba93a1ec0c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Thu, 7 Jun 2018 18:44:59 +0200 Subject: [PATCH 135/135] Revert "Use Multiple Emails" --- .../CollaboratorsInviteController.coffee | 2 +- .../CollaboratorsInviteHandler.coffee | 2 +- .../SubscriptionGroupHandler.coffee | 2 +- .../coffee/Features/User/UserCreator.coffee | 4 --- .../coffee/Features/User/UserGetter.coffee | 9 +------ .../User/UserRegistrationHandler.coffee | 2 +- .../coffee/Features/User/UserUpdater.coffee | 11 +++++++- services/web/app/coffee/models/User.coffee | 4 --- .../coffee/RegistrationTests.coffee | 14 ---------- .../acceptance/coffee/helpers/User.coffee | 27 ++++--------------- .../CollaboratorsInviteControllerTests.coffee | 12 ++++----- .../CollaboratorsInviteHandlerTests.coffee | 18 ++++++------- .../SubscriptionGroupHandlerTests.coffee | 8 +++--- .../unit/coffee/User/UserCreatorTests.coffee | 8 ------ .../unit/coffee/User/UserGetterTests.coffee | 17 ------------ .../User/UserRegistrationHandlerTests.coffee | 8 +++--- .../unit/coffee/User/UserUpdaterTests.coffee | 17 +++++++++--- 17 files changed, 57 insertions(+), 108 deletions(-) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee index 8b1b481994..f74a144bac 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController = _checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) -> if Settings.restrictInvitesToExistingAccounts == true logger.log {email}, "checking if user exists with this email" - UserGetter.getUserByAnyEmail email, {_id: 1}, (err, user) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) -> return callback(err) if err? userExists = user? and user?._id? callback(null, userExists) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee index 58121dae2c..b511f56e53 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler = _trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) -> email = invite.email - UserGetter.getUserByAnyEmail email, {_id: 1}, (err, existingUser) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) -> if err? logger.err {projectId, email}, "error checking if user exists" return callback(err) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index 9463538250..d6ce0dde59 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -21,7 +21,7 @@ module.exports = SubscriptionGroupHandler = if limitReached logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) - UserGetter.getUserByAnyEmail newEmail, (err, user)-> + UserGetter.getUserByMainEmail newEmail, (err, user)-> return callback(err) if err? if user? SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index 4b4dd30ae2..0a0cc8641e 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -18,10 +18,6 @@ module.exports = UserCreator = user.ace.syntaxValidation = true user.featureSwitches?.pdfng = true - user.emails = [ - email: user.email - createdAt: new Date() - ] user.save (err)-> callback(err, user) diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 4ba750be17..2e6d4eada6 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -64,19 +64,12 @@ module.exports = UserGetter = return callback(null, user) if user? db.userstubs.findOne query, projection, callback - # check for duplicate email address. This is also enforced at the DB level - ensureUniqueEmailAddress: (newEmail, callback) -> - @getUserByAnyEmail newEmail, (error, user) -> - return callback(message: 'alread_exists') if user? - callback(error) - [ 'getUser', 'getUserEmail', 'getUserByMainEmail', 'getUserByAnyEmail', 'getUsers', - 'getUserOrUserStubById', - 'ensureUniqueEmailAddress', + 'getUserOrUserStubById' ].map (method) -> metrics.timeAsyncMethod UserGetter, method, 'mongo.UserGetter', logger diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index 4a42d7f5fa..fab438ffa6 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -48,7 +48,7 @@ module.exports = UserRegistrationHandler = if !requestIsValid return callback(new Error("request is not valid")) userDetails.email = userDetails.email?.trim()?.toLowerCase() - UserGetter.getUserByAnyEmail userDetails.email, (err, user) => + UserGetter.getUserByMainEmail userDetails.email, (err, user) => if err? return callback err if user?.holdingAccount == false diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index 22b31239bd..fa9ee24450 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -43,7 +43,7 @@ module.exports = UserUpdater = # Add a new email address for the user. Email cannot be already used by this # or any other user addEmailAddress: (userId, newEmail, callback) -> - UserGetter.ensureUniqueEmailAddress newEmail, (error) => + @_ensureUniqueEmailAddress newEmail, (error) => return callback(error) if error? update = $push: emails: email: newEmail, createdAt: new Date() @@ -81,11 +81,20 @@ module.exports = UserUpdater = return callback(new Error('Default email does not belong to user')) callback() + + # check for duplicate email address. This is also enforced at the DB level + _ensureUniqueEmailAddress: (newEmail, callback) -> + UserGetter.getUserByAnyEmail newEmail, (error, user) -> + return callback(message: 'alread_exists') if user? + callback() + + [ 'updateUser' 'changeEmailAddress' 'setDefaultEmailAddress' 'addEmailAddress' 'removeEmailAddress' + '_ensureUniqueEmailAddress' ].map (method) -> metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger) diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 71982ba40b..009f582b2a 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -8,10 +8,6 @@ ObjectId = Schema.ObjectId UserSchema = new Schema email : {type : String, default : ''} - emails: [{ - email: { type : String, default : '' }, - createdAt: { type : Date, default: () -> new Date() } - }], first_name : {type : String, default : ''} last_name : {type : String, default : ''} role : {type : String, default : ''} diff --git a/services/web/test/acceptance/coffee/RegistrationTests.coffee b/services/web/test/acceptance/coffee/RegistrationTests.coffee index 8bcf8c3685..89d1bf3299 100644 --- a/services/web/test/acceptance/coffee/RegistrationTests.coffee +++ b/services/web/test/acceptance/coffee/RegistrationTests.coffee @@ -128,20 +128,6 @@ describe "CSRF protection", -> expect(response.statusCode).to.equal 403 done() -describe "Register", -> - before -> - @user = new User() - - it 'Set emails attribute', (done) -> - @user.register (error, user) => - expect(error).to.not.exist - user.email.should.equal @user.email - user.emails.should.exist - user.emails.should.be.a 'array' - user.emails.length.should.equal 1 - user.emails[0].email.should.equal @user.email - done() - describe "LoginViaRegistration", -> before (done) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 793ac6cd9f..f83780b535 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -22,30 +22,9 @@ class User jar: @jar }) - setExtraAttributes: (user) -> - throw new Error("User does not exist") unless user?._id? - @id = user._id.toString() - @_id = user._id.toString() - @first_name = user.first_name - @referal_id = user.referal_id - get: (callback = (error, user)->) -> db.users.findOne { _id: ObjectId(@_id) }, callback - register: (callback = (error, user) ->) -> - return callback(new Error('User already registered')) if @_id? - @getCsrfToken (error) => - return callback(error) if error? - @request.post { - url: '/register' - json: { @email, @password } - }, (error, response, body) => - return callback(error) if error? - db.users.findOne { email: @email }, (error, user) => - return callback(error) if error? - @setExtraAttributes user - callback(null, user) - login: (callback = (error) ->) -> @loginWith(@email, callback) @@ -68,7 +47,11 @@ class User return callback(error) if error? UserUpdater.updateUser user._id, $set: emails: @emails, (error) => return callback(error) if error? - @setExtraAttributes user + @id = user?._id?.toString() + @_id = user?._id?.toString() + @first_name = user?.first_name + @referal_id = user?.referal_id + callback(null, @password) setFeatures: (features, callback = (error) ->) -> diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee index affef2dc31..4b57ff3697 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -25,7 +25,7 @@ describe "CollaboratorsInviteController", -> @LimitationsManager = {} @UserGetter = - getUserByAnyEmail: sinon.stub() + getUserByMainEmail: sinon.stub() getUser: sinon.stub() @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: @@ -716,7 +716,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = {_id: ObjectId().toString()} - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `true`', (done) -> @call (err, shouldAllow) => @@ -728,7 +728,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = null - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `false`', (done) -> @call (err, shouldAllow) => @@ -738,15 +738,15 @@ describe "CollaboratorsInviteController", -> it 'should have called getUser', (done) -> @call (err, shouldAllow) => - @UserGetter.getUserByAnyEmail.callCount.should.equal 1 - @UserGetter.getUserByAnyEmail.calledWith(@email, {_id: 1}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true done() describe 'when getUser produces an error', -> beforeEach -> @user = null - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should callback with an error', (done) -> @call (err, shouldAllow) => diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee index edad39a637..58b373f61f 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", -> _id: ObjectId() first_name: "jim" @existingUser = {_id: ObjectId()} - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, @existingUser) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser) @fakeProject = _id: @project_id name: "some project" @@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByAnyEmail.callCount.should.equal 1 - @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should call getProject', (done) -> @@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the user does not exist', -> beforeEach -> - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, null, null) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null) it 'should not produce an error', (done) -> @call (err) => @@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByAnyEmail.callCount.should.equal 1 - @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> @@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the getUser produces an error', -> beforeEach -> - @UserGetter.getUserByAnyEmail = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err) => @@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUserByAnyEmail.callCount.should.equal 1 - @UserGetter.getUserByAnyEmail.calledWith(@invite.email).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index 9a70ca0411..bca9ac7600 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -32,7 +32,7 @@ describe "SubscriptionGroupHandler", -> @UserGetter = getUser: sinon.stub() - getUserByAnyEmail: sinon.stub() + getUserByMainEmail: sinon.stub() @LimitationsManager = hasGroupMembersLimitReached: sinon.stub() @@ -71,11 +71,11 @@ describe "SubscriptionGroupHandler", -> describe "addUserToGroup", -> beforeEach -> @LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription) - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should find the user", (done)-> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> - @UserGetter.getUserByAnyEmail.calledWith(@newEmail).should.equal true + @UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true done() it "should add the user to the group", (done)-> @@ -102,7 +102,7 @@ describe "SubscriptionGroupHandler", -> done() it "should add an email invite if no user is found", (done) -> - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, null) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, null) @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true done() diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index 3764c8a765..cc2b1ec150 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -70,11 +70,3 @@ describe "UserCreator", -> assert.equal user.holdingAccount, true assert.equal user.last_name, "lastNammmmeee" done() - - it "should set emails attribute", (done)-> - @UserCreator.createNewUser email: @email, (err, user)=> - user.email.should.equal @email - user.emails.length.should.equal 1 - user.emails[0].email.should.equal @email - user.emails[0].createdAt.should.be.a 'date' - done() diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index fbea0e8b53..7fb14a7f7d 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -85,20 +85,3 @@ describe "UserGetter", -> @findOne.calledTwice.should.equal true @findOne.calledWith(email: email, projection).should.equal true done() - - describe 'ensureUniqueEmailAddress', -> - beforeEach -> - @UserGetter.getUserByAnyEmail = sinon.stub() - - it 'should return error if existing user is found', (done)-> - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @fakeUser) - @UserGetter.ensureUniqueEmailAddress @newEmail, (err)=> - should.exist(err) - err.message.should.equal 'alread_exists' - done() - - it 'should return null if no user is found', (done)-> - @UserGetter.getUserByAnyEmail.callsArgWith(1) - @UserGetter.ensureUniqueEmailAddress @newEmail, (err)=> - should.not.exist(err) - done() diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index e738947171..9411059022 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -14,7 +14,7 @@ describe "UserRegistrationHandler", -> @User = update: sinon.stub().callsArgWith(2) @UserGetter = - getUserByAnyEmail: sinon.stub() + getUserByMainEmail: sinon.stub() @UserCreator = createNewUser:sinon.stub().callsArgWith(1, null, @user) @AuthenticationManager = @@ -72,7 +72,7 @@ describe "UserRegistrationHandler", -> beforeEach -> @user.holdingAccount = true @handler._registrationRequestIsValid = sinon.stub().returns true - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should not create a new user if there is a holding account there", (done)-> @handler.registerNewUser @passingRequest, (err)=> @@ -96,7 +96,7 @@ describe "UserRegistrationHandler", -> done() it "should return email registered in the error if there is a non holdingAccount there", (done)-> - @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user = {holdingAccount:false}) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false}) @handler.registerNewUser @passingRequest, (err, user)=> err.should.deep.equal new Error("EmailAlreadyRegistered") user.should.deep.equal @user @@ -105,7 +105,7 @@ describe "UserRegistrationHandler", -> describe "validRequest", -> beforeEach -> @handler._registrationRequestIsValid = sinon.stub().returns true - @UserGetter.getUserByAnyEmail.callsArgWith 1 + @UserGetter.getUserByMainEmail.callsArgWith 1 it "should create a new user", (done)-> @handler.registerNewUser @passingRequest, (err)=> diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index 7b3be3df2b..b952a688ae 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -17,7 +17,6 @@ describe "UserUpdater", -> @UserGetter = getUserEmail: sinon.stub() getUserByAnyEmail: sinon.stub() - ensureUniqueEmailAddress: sinon.stub() @logger = err: sinon.stub(), log: -> @UserUpdater = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings @@ -61,13 +60,13 @@ describe "UserUpdater", -> describe 'addEmailAddress', -> beforeEach -> - @UserGetter.ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) + @UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) it 'add email', (done)-> @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null) @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> - @UserGetter.ensureUniqueEmailAddress.called.should.equal true + @UserUpdater._ensureUniqueEmailAddress.called.should.equal true should.not.exist(err) @UserUpdater.updateUser.calledWith( @stubbedUser._id, @@ -136,3 +135,15 @@ describe "UserUpdater", -> done() + describe '_ensureUniqueEmailAddress', -> + it 'should return error if existing user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.exist(err) + done() + + it 'should return null if no user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.not.exist(err) + done()