From 8249f4e17e573e02ac862be787be1737e260e738 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 27 Apr 2018 11:22:20 +0100 Subject: [PATCH 01/26] 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 02/26] 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 03/26] 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 2a52eab8d6a5a707d55374cc80d89703f560c8dc Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 21 May 2018 15:10:46 +0100 Subject: [PATCH 04/26] 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 05/26] 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 06/26] 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 07/26] 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 08/26] 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 f4f3a4375ba703399d56e86b7c0ff1dcc2d8e3f0 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 22 May 2018 15:40:57 +0100 Subject: [PATCH 09/26] 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 fb33fc6c30ef295255d5e58d08e870356584ad9f Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 23 May 2018 12:14:27 +0100 Subject: [PATCH 10/26] 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 8d2189f843df67e077610906a3ef1831adecdb59 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 29 May 2018 16:50:15 +0100 Subject: [PATCH 11/26] 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 4c4a4f10c13e1c19046dee4d3cac51e6cd1a48a5 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 14:21:01 +0100 Subject: [PATCH 12/26] 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 13/26] 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 14/26] 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 15/26] 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 6e7e76a3ce5243f02ca13faaf807429b5c7749dd Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 30 May 2018 17:34:46 +0100 Subject: [PATCH 16/26] 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 17/26] 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 063187b5fc5f0e8a6171f5f2780beb53c46d97e1 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Thu, 31 May 2018 17:03:41 +0100 Subject: [PATCH 18/26] 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 7898a1decad4be7cf1f2e44d4e0124a464f17993 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 4 Jun 2018 10:45:23 +0100 Subject: [PATCH 19/26] 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 10cf5825a59ff5da7de73ed1fc75c5e2b0b2178d Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 12 Apr 2018 16:11:52 -0500 Subject: [PATCH 20/26] 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 zcmb4qV{|4#*X|!dY!87 zTHRf1oxRVlziWSY0jM&P(vko$FaQAT-vRjh8z2S%2m6ox$AkaJAR+$ap&%h4Afch4 zq5pfrz`;Vpz`;O6!y>@K!T-npwGa{D5&v`XpPT&Oq2N#u5K!VV#5dWg65UBr#Hg_4UaPj{llC$~vHLfKcCTF(n0-rt}UYae>g?CPt7CQfAb!ziEMbYTiXc9!vUA(XH^P(9PGzi+qU5zQZ@AuvWrWd?h-Q&gz$V)PuL#BDt3iV{1@scCdaIoiGOdjJgX8o1$_o2h z;`fW{rN1Oi?4}_zS(0DaEvPe=HSKh2i|^&k?iI~_U>--D{is-_O6G$y<3mHP%+&T- zX|@Emxas5m9QcshYPp|OgW+eFI}*)zY*r{^9AO;cvpV`!*Ow8&|on^K{B zQK>YhB+t%y{h%E8EQSce*Ee7-m88O262Un)P~7=1;EjcDr1tca)!*3FLdd1tV9$x#{7hH* zKszGlYU|+7`AIj@!xP>2qZ%{nLcloGx4(U2CWkSY^ zGN4}qLBFh=f}tSCQM}OP7^}Ap2;#Jtcv}{|0Xe*@TZec`JYmS4R6i_{33OEjV#QZB zH*t3}T{<{s>u{CDI$ETbAtrG45{n>R$6tWpKSg--3}JFP*I1}Ax}a;R=fd8nt+{; zoD}jq?YLzN!&dEfHw4BvUaCM2>PhFRRF^F0a59p-UanmCbPcRI9_{r9fPE``L;830 zzW~j`INaY7V}0?WUJ~eU0n#cy{LDgy$JpXGr#MA3ESUD|K?TP-K+N=SF?;!B9QbzV z`K^nyTvw3=?i(H-*P-6A{574!Xvc7~;*N7$oWJ@9DUB@-GfmaU`ru|2%3#uSe~oJ# zFx1>Uu}!+0WW@>C>-u(NCbl2+**Cj+4Q`)@vp>D-_#ZBlwBRtR$x;^6-=xu-F@{$P zndaHQb;qT@Df-K^v%9_iS@LSlThP_)=i*Hh;ZUn~Ez90nH05V*c0VL4M7u;} zD!;+Eb@F?emq_$ZNRqNqH4W3J-<@H@mxFnu0bw>R=YGr1f!U_1g<>10EjaGspk#D! zwB*j-^t;E$_f5u^iZ`Z~H-grLF|FMPJu=(&4$n~u)r;*KOI1nNvVz%-eULk+bO&bg z@%!)cPlVj0PNY4-%pcpw)uoNpOH840hYd@*KJ%ICK5iXJdD&e+YOrQYc~CO}828R8 z|FpE9;k;1JyV$75$qv_vsw}F}PHvg+S1Fu-iv39#aHKJllSfg6=mD8>l9)>-={vbe z^JbZ?i3eBzC$C~SwLK#h`xKEiljC1N^P+lr7k!7{Pb!V}<`4o`{q+sYT*#zTx^b3S zu8M6w&&s;dZVQ=S@h#kV!t z1}=$bF>jez7w+6TH9G%@U$+%O;$qNO{)Z^5k zV7FR(Q&v(7E^2I;;NEGVIz_}K_2~4&e z>wG`-=v0 zpCMy0Qt2}uXwkE`Cq!_(+4ERzl0EvfZ`!pbCmG@~uWj{Fi={IsW>W^QKY^l*F z2Kx1Nrs|B?ZF(S{n?eui3KazmOOt*_KfS)Q`jx$tO{23efr!3V{uo`8(q~3HkXgU- zrV7gx(GgFEMss>Z$wFXlAx!Cm)w_WPUu^VfM@yEv$}pZE4E9iQ##LWB9iDY@`Lmn$ z+b1%=GXw8BqVIFws!^r>V8Hh!>FlG~8BWi!1NU#n9?Yzs^zI*HyiD%qxblPa$cKWWR(uV# z9fJ0~<>#qFQL^EZulA-C1=D37azw+G#<%cCuj?%=;!-}D&O_6hFS$}89my#In_sp_)1C+NK?Y+fHX(2Il<&syAV>0Y@QR3l6G*K{4>kr%ZDGj zijt0_m3Sx=*Q3*6!E-2l8t8eW-MRuUHUpnw=Ib@RM}-i8q)Sh=2*_rA&QmV9vsRME z`Y@u%!1@r9g=Yk5aZcJ=WJq^tNoYP+4^sQIMrdn((Lzg`{(g6wi(gqMEq&isMq<5Q zccezeu#JV~rPa(2yqZcew(AB{VXDxtAES7fDb0zs5}hzfi;_*J+h(H)9^aBaj>CFu zf*n5(FLkrEZ9fl1Czz3<$&4HOPMg-%N#6{iDYjOBWb73@nm08WIelajt-WpkbOG4n z{o%~w5LtC!t*ctauxs}l5Rz?YqbU}LX;xCOK6il2hYn{sr9@#yoMf4`lrzLJd2sfL zGv$z~58pnTX)>AXSlJXzFA^V$=~w~k?!mp;YOL~XA+ySYFOpuPY`#@KZ0;_EVkjeT zVBzae7pGcsJX6&4Pi+^dXjO5sbP|o7_YaBSNPvZmk2X_kt)ofu*siq5AAksjG&%G~ zjram5X0Drh7wAKlF`Wv@tmb{M$t$!_2zUK*#V|;x;9rLxz(&{|K>KJNVXLK-QFZHjC&GmZk6pM6eVi4^quGjXGK7WMJ>)SHtP3^Hua)xd*ahbp$It z%akS;|B^Yu^92MV->*&Qs^S|9F#0@=g&l~ zzHt%P>BR+enW15Yfsy(697&{OcJ%lghE)@lr5s6$NB;m<3A{}2$__LWu3zsGc#j5V zHJ#64$n|FmodyKTRcLa6)VBdmFC|vS4Pun4L?-l=6O>$irwLT+~5pMeKS5D}VuwBQk-_ch1BQQmCQd1Oy z*pco(=ej#XEKyiD9#VC;q?IS__WPaUdQrm&k1n*{$FUB)2Q*=}o41;j=5Prf>8UNL zsup*F>}$n8T`uDK(XL;?P4(9D&6yE2hbExFWlT$3wjE-bNXN^y9sWT`denaq6C51u zA9DR4oCyI24haQ-MnlJd!6d^Xr$8nB#=?rtjPnmt!u~^|U{K%(+%s%37db99ZZqwx z|7~ZRlwzAXSuUA)XwN5XWmfsldCecVyUk=Q@>)j z=Ss}dous%-v>)l+*LdOZXYBA<+Fj)|w(yEl)ZFZ-r~URVQvB68O`3o84F4ur?kCvL z>7H>IcAg?9x#%5)zh$cUqP@d+;(L=Tr(*dFGrf40ik59WSMUC7Mo57z=HR(@M6op% z?vKV_fH~YBoN{-ZDYJOzxRWG|sYqrvqO}S31h%*VIk)B3Nu34Tm;7e*{rxW>FgmZW z)G6EK1mQ2B_<|d2VB^y&4EBZKYNk(fqn{I7?5#R=KK7U|Gfsos=7GgBjEG2tf?S-0 zUSCJ8qDuhoX;#%(!A^VL0tE->`TKeA8}(V69mNXBJ$A-^Otf1+IPO}nJ|f-=v3jrO zc~cBGl*1*666RZw0^l)PlMRN4D0g|^GN;Nt%6@JF4PTOp>HD?XjF5p#i~G!4+{_v< zJYnqM3B@n1aig*eL|zGN*_O`PJXCRelpI;3Nk>=MttQ6zaD)l5r#X^w6?F(k9ra5H zgQGKbRd(eWR};-kPEUO%zyoiFzTPnBp~qv$pW0&prq{XwS8RA%Enh=T8Cnm z7y?y4OCWvZ9iWY;8yM-ueWV~kGMndJSolWvq<)u@=+`kym16ImCGMh*<)oj}n0@s} zeeJ~L7iL?@EGJog`1EgQ|7Pf~{ef#;?t97b88fs<5TqPV9eEr-xLIO*sg3U-u2ZCF zTf*WvM-UwBcUE|Q>jGD{VGh?33?i7p#K_y+j*Hjo7J~Xl=#P6UkD1rdA#Y2&6JqyL zL0PB!-*M@iFHt~t>pV4ZGhq5Y^kLE%=F=hOG1@~2la!MnK zw7LpAge%3Y=nhdKUfV$-c+-yAl0MFryCZDQ`dR`YMQ7uq2deBZndgM4kZcQ5rXDe# z63e!s#8^T0^`pV7ni0E0f8I||qETOg!OT=$m!gVp5o*h8izJ*kD%_ceF{2zZesSo4 zIdEJvS^kJolq`xqm&_Y_)^A1K6o6-RWq6<94(`aa0=~67UKS~KN$H2`B2LxdqSFRL z)Eo}eBK#1W{la?<@d6?I$flhVR!a>b#QYUl_rzdfgAtqCdp;iT z8AM6+htnR5G0TvF43dE5em4*|^Wual$8rJxh_oMKvf12FJ@>m7#5XV0S{J?FZ3=?? zQqeMuK?eiBy`a~j1hHn^DR$yCJHeOSiLyC1AGDX9`y(&NXSEy9inW|65`>?9+mt$6 z*AwF-wA6id$V*wvfgweWEO=GA0@h9Av965R5DSW>Pv>awCJs(83+?=N2C3s9^;&i> zP7Q3cP*FY_Z^3QkGNgK7g}Aep)ja%Ggvd8DozbSnCp1D%HHX@7IJEe|{-V|Fq539c zq4s6`a&3XHl(g-ZgyjYW{u^wJRRD>*$eaj$eNzW?>~i-b>e8tc)Se6$?Apcj<9;g^ zW?nhyXwDoYW>-gW?nl%_3N=Xz32j0LGuTT-`)y90(p;utnYFOSq#wcQt_e+qlufkY zz8+XCe*rwKxHHo|p-$1P9P=%+80ZLJI(HN{Pq=+%`p)QNG~|^(n`l&3O&<#)Q`s@- zVl?iuBHJl#mHW%$q#KQwy-fV*c#3Qj2GJo4ywt=@9qA&wxyPo@!ywu%e3WO-G^>r=_~7ft3>w(>t9Kw--rU;cGimO)+Urb^bt5 zg|F6VZ7{Qg??sFRb?rBDawD?`DA|S}`)owk4V`2OC-dmoZh)33GK+oExS;6^btbj$ zt0`$`YnMBmY}}szP*6__vV?0?n){-Dl+2+HY=e@~DJ5RitGt06aP+iK@IXxZ-?%wi z6y&?pvAK^_PWh`?ietFagG`-MF z*FTVPn5LvI2fI*E3lx2hu{7)MzQsX`3wK{?m>`4~a%2#VMVU5etg;R+2|3XT6nx`p zea>E;*4BD1EPYXHv>B{knfy_MvS`|wn87p36|-IDX?)45qCf0SuUO9G8F|xpjRQWL z+IaA8;7{2b?f`X&u`i&nS}oQx{}_4Wzl73Uno475ai)P)wP8a|V;(q-@jHZqaG2bf z@OUJ{s%WyZ(u8@;tE_a@^{yv_qYp`-1ujI28oq_pSVC2!U}uZ8`5ExX5tOd}`b2xJ zWm3UY(oiulEj^ZjHokjr=J63bKG@rAfIcJ2lI54aJOT?V;BQvW=EggW{F~!zn=+_$ zYS6|)zrsE!aZyXDzEKpw}TxWWK z^l|9^5J=Lmz4|Q!H%nmBG{U^csRX`%n?}*|NK*qqV4k0l*i~P!*ZL>R%SVp(*2J6j zc{<`EypuRnvvl#N%}sN=zwxlg-Lo*dnNPl4zD#A=h|K$jbwjc+HvwAoX}x%y(IVw- z1~0#)mYO&wHw0H`X?nIDwrmol_Gm-wzF*ZyYzvne;)HxL};$a!Oc{72N zRj|cVBZ%~bVO{u71ExcyoQ55%5a~0wD*QSAkCb-0mQ)v-)LPvD5a)90_s4yfQUopM zI_CI3WE4k%5FM;cE=>l}70J8up==JR#E8R@#?aiQ-D=BJ#n|AauNk^-=Owz!7?x0v zYm?*gbzC#@)YeUSc3knX#EEn&Jakc<*!WI~?zNXhtx@*o!OO9~09(5KXnRSU_Qvy% z)46O345HLUOaOMpck?W5dHixre)1LP4{@pe_-qE|_K{ixC2=_*+B&X)&R;<4q?$X) z!8g_cv1~!DTk|?@vj=`!bH*9h^w_i{j$`h?GFvHdK27owdro@KWJvK%$etY+>#s^_ zF#SaFpb?=1J%hy}9^F`xj0XD7z1?0SahPs3G^d&glD1_=aog1hclFUk;$df=0emB= z>`JAY1El*Tp*cL-UDCFJ#{GE=;E=d1%Hx!k&ZP)PJH$IM#xN>iWdw!PqoXGY^sSN^ z<-VOqZrStYTyYk4OK;d(m$DqlICJcJH|+K^uba3dI~SrI4gY9#{Iu~WKLWOJ^c=k= z!~hu<%MSN*83smp|0dn`YvL5c&M-M;`@d*Z>Ze2P5e z#$Wxw`57EMT}op)r%}=>@8&!CPy+%NiUEPb&(9_n`qSouvZfj-`r?q0IN)T-plZsP z_kyMt$2zf=LiE#YCp;*_WLYjDb@?98>p)8vF-3Dm`)7Mi((x>Q^Vw%Sq@XSe*e4gl zry_}keL@;}tlyO9=MN2|{p8u|@Kdd)v9LdSm8<!~{tG;umy{4M|yw8)VZmZ$FMF z8VpgEjuQ%PSQ<NnrYvX9Ug1uiC^l(>1^+nk8+ zWL6WA-iQD-McEq?YaZ!!v>kN$+o4yQdDST^2)IAIz6Mtbr7<0w?MPsy&RWli_Z&Xo zY?X|Dio~>oo7M=oTQ&@aPv(Hck5U*XlPpK&5Z|a7`LT!{*t(}?r8!Z4<}4SPg?T>X zk4H2#Em!aNGy0WU?RckK>V36Um?Y{+H53*uPP4OiZUy;_4bLcnW zY}mT{{t2f(Sg_3PxtJJ5Ja^BzQa74?L+>-S<6#$ZzQimT`?K{;ytpI9Zre*XBX@*? z3le*n*lL!zNkMAj2v2S%iMVpE)??M<6j`f79=PW6l!R1MLN;4bPfw&viTwQ`<*}~{ zy!k91CaMJeIVl=Q#g#@WI*b$aWWCvwFQBW>Z~ssaDk}KUAWeQgislC^?539)k;ueR z&bVsG=agEtgUxKI$BoiA=!aZED{8dth`uA7sbI>%x+%i&wW>a%(+eGNN+S7f(E}hs z8s$)*maCtLYFuH693j}BTq%h%>CoiHO@Eb_?BQa7B|MgmTpkWS?lFVrpdNi){k`?4 zFIz7S&bMx;))kG$ycsiLA1yW6N3No|760iTfu4&j#<4nIo~Xz;zr0GCgzV=|AJYw` z*=47k<;8ZUmHBU~VFYe*0}BCiO;w7d=4%V_w8ITXyhHmJgV&Kc`(>xkVi@-dQp&Su zeKjT%7Rbn@Kk0i9zk>Ey4vMm^BJ7EUET#f$kh@SitwTXPnAhq+EQHmdKJw8UMF`ZxJp})-3b1u9ql9+YBBq!vR^G>#2Fccznbv z#)M0fhX`fiAft&m(H4zJU0GX$yMCFOLcKr!Bo!*rZuRFTZ+sS4ZF95*)dgeuX{s?p zfgM^;o@M^}pN+wOA2uJRz?Lj*{ok^-gP9b2$gS0t zuULA5W!Y72=k{%-TT|^G+MdZCV0G+#q`5>XgC?B{xkk$5Rs_d`oe%g&zX<)KUm*Tz z4xpeR|05pwSF8V5v`2$Lha_VW!5~+LLS+>-#w1m73QD9faju7E6AmuG66^nFI zGQCumd&sg?3kJ4OD(K=G@9W5xvpKS`GW}+{HnSBksS1fFKHecs2mhW|T_s$x7cydx+Kbof7OdoH^Dt z*aCYs7fJ&?VeWj7T_k59_b{>i@f_8h`0^LH4_=Q!g2ydky^C}Ux6_Uqi_@UuWA?=F zPV_((gR`a6>{Bbo9dj(0??>W$=jaFJo(kP}sYlT5zzzIChs%(lC9A^?tXaevd+Tv< zBA$RB=QH;)d+KF_%=?5nL7OYvG6lkW_RXNy(b%b8*T zML(%?v-d(Gy795|L)}rBLMEhR`}%13A1XsI+v<@gc)02=wHA5g!!X zGK;biv8C-_K#B!+PAm+$8{PQ~J;i{BY9{-|Qsr>2WOK*IeCcK682Fl!CZF53Qi;O; z4x4TVzq=Vns~MfKf$MEDi`>!r*dpiH5=3T%^T=JI6q1H;#lC}R9DYS!m=6=FSVhm} zI~7keht0CtQEl(O0VQbJrqTP3n!npIOv;fa_JwA--1)xQi3;tfPN8ltFmQtbw6_gs6=KqNRO>X;hWiw1OVqSt>9@222PlF^ z?&V@9QKxF+jy5>}O-wA&D8ZCgVwA_-zdY6LNTO+%r7UCAjlq7sl35FV^)35>rl;~r zitQ1$yNYTD?X;wNQb_=(iKi?=Tz2a`R*#!VHXc+KQsw0kYNdJKH3|y+Nm6I{$lcOX z?hqJ@(=yLOt#R4r8Ld_l!b*VNhIK|j!IvVV$D}eyBTzL#Hu|MJs4?r%bXn!SH8Kx$?DfC*p2bu+@`=)5lmrTha=W%ctED8__41upU6bZYS zlILi|G{jowT}zhGIg62^^OO0YJqVs)9;?_Pw5VN3HVsQA+d@>#J4b0^hyN-_AuQZ$ zy-t6(P;kcZa>-dGfg;z2mU>m$uoy7jz05KQITRvaTrI3GW7_Y-9GITiCeKQ(H1uFM z$GRMppDU1v{qDs-+=B^Ll)=Sw5pTu}Zcnp!iXDDMcOI8e?IP~~GqEIi2&U0e)EWyS z>j-LsI%BkNJ4gLR%p(OE&r3=|;TPmaKlOR6#v)G`Hd$z;c>7-fhH*f-yCr%7$rM@# z^4XT(AL+y_53fk9zE7+N``Wep?KzYp(UbmGq?zB$?uF zNw+6&*1p*;b2ymSa5!+-I8b|p{mIuKmeE``1roI9MD5#BXW+@=QW!`*wf{A*UZrc7 zKCfqO#uJh_v{EdV>220FTY`=gh*H=X$Z^_$j)SlsNF4p{I_QlWdqrXf;qHSWT zcs`=wR@i4|w>!pD?kginbJfRtuVMoc=#CmwpQpbIqHlIi8Z6ne)b;#mmkC6*F2%VY z2%L*u66Ihn#7bFDQzug%>Zp&5RH&lqEbukM&>A4teb(})z(;U)a=2_GWEjtYOH+2B z`BSMkhL)i0u-HjjGI77PG!R!QF+tH5oTIsJ-ivCjFvv#r7%D4HrB>?7ij9GOnwjpN zt9o|LUDlMd0>VKn`tCZZnoV1#LMB)-)f9}$GsE6LohGc7BbiXlmDpkJQ>rSfo_Nn% z=6{8m->i{6raEs4uc;2VqKqt5B9J?4%ts^4MeWARG+jPPy|zvkvDm?vbFWT2bG|M= z`EA?K^DtJ4RsHsAd1;Cs4yY(TDp0(L-cLe$RZ<2gO1u`w7mF<^oMCG#^T<<(yc-*|70n%o7jcZBj^DFr zI{5P7CI=chQb~_b!d!3l>5;xwGL8OOg5p_Lrh7-#^(IFrSIDmzDQC^gVdm5AQy{g| zp-lIJd7x8jYEcx{*Ys70gCBB!XPLQ$N<3iW-K?#d{PB%O!N+>bqJX!W4%@^!!8)96 znl@deyi^c)vHkI^Zupk^gX{+LIP~DW{?eY15A}o>c^_I9PJSwH6k}_+N)x$q_foy7 zJ|^)kjZw0IO2d-U?6?L(UQKlw-&0QnmA}C8u6KyRxHBEU0A|0_j{AmV&KyymToBb+ zFSVk#eadl(fy+`K^P+o-gijjQ2qdUFdVJ$oBJhM)nTEcF3WefX_&d?LHgqtL03nmw zZ**PB%kfv#ZOD1eX*~J~N${G~S|;L~YuIS*56-PEEz#zj{8imb>lI|th@bp*U&ins z#I&`YwARA!^gk4a97Xsb9f`6J6vF0JiaZ|G&sFnJcoH+@i$3I~uxFwJi9AxANj$tdVD-!Q=pL5 zwj?07Fe9opU;ah+i3^2ot5Q~!)pUt5$xz3&jR|M6mMd=!MWK+2gT-F}vIJk0RgGI* zB}U}PbF8mDU4orzC1o)Q>!5oN1%}9LW_Q8mY@d%{)4}Mx8n-38GP$QBuYZdCE8Wbq zcjJ)#h|rM}U!G{T-n)5zPD03(LRb@sir%=!j-KYEfOQClu)uO zggIs=MTHpIgtI0&7SrMo>w#p}pr9Gm%1s6=!{&^^nzV{)xt%aQQ~C%ySq0+I#A~z}xS8Fd+=( zCwg^3kFsUVpQWo}Mm9i82~c+>uxQWvk?uQSq^0UyrU@$Hnl5Jp=CsNyyzsaIdTT`4 zB{B3uxmVrJ;kqp;4%c1{`BWa}E!}&dQc$ZI+V;3a-|nSTw&?Y$O?MwF)9WL|?M~-(|7G2)4IJ+ieB~11G>&B^q`WPTHE(q`w_bS+$L03izX0rZ$d!5U5~CZ)<2d@JKO9rV z&pKevMYA8JI>PkdJ5J-g$%Qy=1%V-2<_)`PKLiO^IjDq^V}3a%TLpYebP_Dbe+{&p zgr{TabG2bO@LO8*zZmJV9cgeram@=Q$Nz&jM-OZ`kMvpn#9}@EkP^RC_YOPcxz z=Rg4{CWa{nzC!1l9;hDk_)9gxmyuFU=A#V#0!n`Y>iu2VEn*24Nw9pdr&&Y&T)$|U z^n971bG@>1Kzqh(1SAktl&n=ev9>&LuqGFciU^9=K;mufWO1=dUi|fS`$oymaJkRL zslR}>09W{Gnfxj4qOj^o+X9d!Gp9n;%Ka~z#a_E#Hht9A^54PN7qF_@0-WQ>lva71 zDwN#lgx*tFH8|UIwt-^3?bz-2>P=xq5A^j+y%pZvdTb(3VTM>*=hI6wHI!^z@_@cYXRBI2cX0Ci%QeTaITcP!S?d za?eIC9~%ti+iyoUxK+lp7OvV*Gtm0(dd>(1n}PoltIg$?8X^G$XFc8kb*+FMSHIz)|KlqafINU>Nv)2k^KCxU)%v+|3FBAU|kpyOvZ`%m1idLvYP!9AvXaF2rt5l zxWu1{2Eb{h0{NaYR3`VS<=-X7!Ho^2JZ6{vnN zF**>0Yg<(#pm}`lZOs;w5XRD|b1;QoGOAE$1Zx6M<0z}aC4(G+kM24a+#2JmPvIU^*L%W8?j#NuA*WwITH?WZKzoquEY&6#gJdV?w0#uWwaangFXt?$ z;1B-!1A>uKG}B8GbX=z5F5V1*H^g^b#Sy5Z2y)57qifnWpLQ3f=4Kc$cO1i^)2iDc zU5Bvu$$y95Xqu{fuiReq9KSj})K$rjOUORQoRk*!@sDt)<~Nn}f&L3f9u;a8bX+=5 z2Ueyb-tb`cU3MYR-jC9&b<1`FH;uSd>IIrMZ`Tz22HrmuRC~tewkxaEby%A-soE~^ zs8w7u;6-u$WTDLpS5zki$4rHJ_%gVnKaRkb6+!gp_3Zdwp1lDZ5`KVAsMK^J0(;OD}5N_$~O^ zCEL+qh-}6!P&THVm`5hS_#F0Hv?(Sw?P=Qnnsq&cn~rLQqHlnL+K2D|_%UkKe|*?~ z4Fd3QKg)mlF|>c&n6mLdN({B2zJGr2_Kx&_rAb0$wa(oWFZ7#b&N)fB)C0bhU2@)O z=Z3YDk~Z2QggHaV&P)71AE%D|l=b)xn-=3dlRYCt-7uCE4MS~~jJ^Qe4} zL7p#+m;R=?w4o53{PFg?Eb0c$MV}*T5?dETLn#ygRKX+|M}GmH;j`5PZIg^RnDP8Z zgHLD_x8G%noe^o!6NX3wKj2g~cewjqJV`km`Z+~@ZaK=|5gK+_*tNoa?A^=f-7s4_ zBl5<4NS*VxN9>L;i$_7$4!*WKe|*v3Qhq+E+r#(`8OnDxI{38lQQ*&z|Mp3x!0+pq zLJL(*{0(W*6>_Z^WE!!p_NAYxuZ*FgTkvkqb4@@j$rw&L(p=49%9;1`J8={W_6j{;R;nxbTm^YR$~Ldu<6PlrS-Ay>)C7Lcvur>8LXsY>o}(8BqT= zOchjT4pd+M3t&a-j*V{4Wb-n_3-XMJNdw492ZxtbDCz1pUnA>@EZoOnIMkp1`*QNs zSwq~IEG+ENmN!!`YwCJEmr& ztvw--n2m*}UkW_P9ei0hpvf9lW%f`LB7?L)|5Z(>g@Riqq#PC5nGPZ+yZ8s=`{-pE zv{#+O^*ZEuC=gW!f((vee+OYH5 zb0>8_3j2hKW={Fd>0wzWpu#EhTw$VsPG;PWj&J+0CGN}_D?WyW2%2acik~SzZo}#k z=nhz(c1t{C%~vQ+9K|Pj4E5ygHw9%pPXPwV#^t8W7TG;(WRg@D5w{}l;~~T*paP#N zd3)~Q9-g!+#}ycV@&e2}QqLzQ0Zw4`)*EH$t%bA4%+BJUbV@S)%wz{V=>rm$(9ILH z3PkN2@*|11pg##bbl3O~a6svLp(^N${xfpE5y$3vkZS2*l8;QPWphe`VePE_ctHP z=~PUp#2>QxI>{2H-&cewuQ@2{zA}@k-naAKVmn^@2E`ELsaddS4v%Tuz7muX4hofY z8+f65$hq*1RCjYRpw`JF-zKBm&dv4zk@yyme*sId&eet}e_XtWWT~pjoK&|M;&$=NCtGPL%P;YebD?lUH@TC2Kp$V=I5+ z@V*&Hc8Te_rQh0VgO3I@An8QsQm7NbAcj`;Tou)!DjPCEg_3XZeQCJQ2`u6=l%_2( zppZ&K^-5k$W|P7CbvWbPo0mA7ECh6~qyVF&<6j=GHWw9yXwu}g*J6m93E9QLqJ zI8@Bgenn@9HkDp-M?!G77gOK|R8|`PB?1IvH9NE*%0^TTy0>7UtEe%Oz4NS_s>qz` zAnUB>qP9>cfY?x4B-KzVe!f1y&BU)zgz~WrEqM$4UdsQ~*pb*6jjepA19s!Ic?h?g{GvfFdG=Yy`ru=EXx2>ODcjonh zdclhw0M;VLg73j#8nyTvjSjzEghcp(1`dnRH)-**G9$$#aB{BFqmTe!wlX)H+uid`NQ~5m>{+ff3_!?&}pU5tA z=n6~kz{2T-Rtw@!NNPa_?KnZ^2b{PhPXc5Jbt%i8%IN-Tc^i%Y>}Ep)=V<1H@lRFl zbYgZ;hA!VUdbr*LCrfsENDLNu+|SB$HCkg@aDB-*$)8~$LxS|229+IUS5@H8c=Ftb z$gA9fw~DTP)1RLv|MY~u6H9myDH0Nuzwg*^HQ$a$mVcn>%Qa_*Dt{kL2!JChL)`Xp zY3~KPQ32>~+8QK7`dynbF(iI5s7kjxNtHQzlHbNCeOAPDrA_%aX^KTsc!Ij>ESfYT zf8_-K1*~ z4u(ENoG2%KGMFGKEy2jkHs1cW&%>6SD~!cU7VEDqZ!zv!Qtpg8G{Z-4YR2?J$Zp2B z($mbSy%IfgXj=N6wX@?-Wu#as`z$wYQKT}V=8__kK826}NM6RBHXL&sq-;<-7NW#-CPh^$HIuF9-J%;Ur0m zuRzBHJ9*_Cy%5?Fsb4(66R$#TiRFTM@LE=7Ze!{-R3_*Z2g8S0qZ@$L_gL&hyJq+No zgGTf*J;^Z1t~Omi$XNkmOh;DNPKHd*%Fd;{<|l`(5t?vhI(AA9B!p7UffSkK+{w^B zwqUUj2LFy?ou@j_GLhLrX;^L&QuHFwMa89=A=l_AD|zJX<&~0O1}W2#c1$nPCAQUc z%(f4g+)sVtN?CxXGcy@zTN~Sp$i%O2Ov=FJ_E&AXuiEc~Dy;0Rj`96rc8@9h>Qqaau zpP0OvKII{Hk};IlX4K7c3|6E^J+sH=V>-vHLkU5YK#w?~!Be4N$|6Zwzhj-;*Y?b$ zw&E-0zUS3J6P+eBR8+`tICOPGAft_26^pQ!snLkORxBqABww$&BjNkPMzXGOQQ9(v z80tOFj0ILW+JT-Ry37UzP=`b&D_kj9ANW51hzIz|Od2z>7Ow20=i^EzFFB3f(Qb6g zCADDQhB)>A+c!9}Qjb*re*nHfLBCfQ+*ezE4@p?PlL*%DSzNBWL9U2sVr)n%vjTjY zV^KzsT9t~HX4mLPI;b}RIow{-upJ+aLB2g=J(~w91n6THLD-x45%3wcyLgMXj7(-t0K9MX$Kd$Imz+*9`J9mB@d9m33YNzP)g$%3_{d8-%&Wrx z0MvI2Xl>?X4NW7usP!#aUgmvOl&f=q;tx|w$=53elQ)TVeTY}O&3 z6)nN@q3ht+QwvDYcN=%zz?D|-4Rd?y2FqcN7G>*Tp-aaG*rP8qQPWx7sgs;oe8EGk zK)?pvOnHoM`D}>}F^Kt(Qo4c5@F|#WTP0rk!J6;wXO~d3tz5FkMSXmnP0~7ehwgm< zKO9SOD6^~ZdYA^dT=;ZAwU6w%rT+luvb=K4!B`tP8ae(xWm)DCOI7g9S`-zyplE!| zjY}1Ss_C*`@lE!=51E&~h8O+wH0XQAGr46=8)D(F1q>_-9;)q(>**A&aNhWZR(d+P z;aU13kVYb>BwW|ZX3*-j%Mw@oBHY9dn$}@g_@a)VaAuGyuiO%c?jm|!uzdWaxc5ds z_tKnS4iPdo!>?JS8lkE;^?zW`#2PdGo@H+zvRfjr0~1vt%R3&WblJue$BAI_jpVyK zGoOh?2MOPpv8CH`puB_)&QF<&5>PeA{b$LKbK3cfY>TU7#H}25*4O-e%lZPbapKL@ z{7(#CHx;W|QR660wS4)C60hRsZXXFV4Rz?t%xNIx97D`n@1RQ)YC~-YMI-)=2bp@P z6 zmK=W&_cUl(x5fhIJ17fXyv5d{bbhfUyQVXB39lm%AAnbw>;7jF+YOHaAJ&MPLo8Vm zW4&%>)=oB)1giMQ)HvxFl|*OQEP*cIt%+gw@;5Ik-bmEY8bJUq=*P%&X3E~P(IP9+}viPYuqwKZY!@6)aD*#tfwuW z8kJ3aHAEBD;X21C#Yi=lx_pmQp@_^4I4IX_zjb2s85vJU0^*1$`WbEt!qx`12BEgiv~wG-el zTq^A9Ytp*EicauuHUJ@ae*&6u>5fz3U@YjWC_ z_Z)^&)GH$Sm&lCKwqCPF!X7>^GK`)EG1MGUE^5_$24d}WKrG0m+%W*A3Bz)!igO+~ zj-fF1m@f>+yiv!@t)w$A^z7u9K%$&^*O_tS&;_npXEfI*rd@_O%-}>4Ec|vlj@xX= zLqyqt$i-(J$_m%BLE}x%EL+;OdoOaJBN~(hR&OjyhZ+9>1ledk%QB7GKq>{eP(sf~ zwk1GzBD58@$LZcs45!Titw8(Et{ISnxn=W%6uQx);}&GUAHiuLg*{(FEMN$9s?%eu1TRXo4PoOTI-lNf`^V@ zqxUS~YcOx@;ZsG{7VU5m?N>#BUBOn5Da2*^0noSEUCUY%vVMvu~&t79B_>k!JzRvNCTI1Qoab%VZr= z#jS`QWvq0|S2N$}nlYMI_RK|&Ym4hLr|K1}mGddrrOd(G7pt5H^FLk(QrRd8TsydS zmEis+AWsBQGyrYA{-qrdGSk#NaljKva7r7friNz_!zwEx5aCJ+Gt0{{X70s;d80RaI300031 z5g{=_QDJd`k)aT=!O`LH@&DQY2mt{A0Y4D7^BqLg^y5yOVO9u%kjt1((+*msSE1NaW`eV4}x zQI$=I3^lGB57YifTH(FTi^iAP2L5^0RX-2O29)JM6>kBqQ4rl+*lT+WUd9&iFblAG zs0_<4&gopn6A()GJ86Br%O|Sh;3Z9S3Aaj+Z}EMnuHsdpeKNCfWpHld#U&2%wM8*2 z<~r>XVA4*!m^ls2MPoi}R}zpIU7Wz$O_@~^-badtRkat!5vgaAUjuu1;Wr$|v07-u z%MOcM3k9u#fga^1$A5IXhN0 zDDEh`aLV3}W6=O<{A*-L5fUC3#HB#IA(rLfIsO zt3O{7snw#|^>~Hw%w-tFzW`IzZbY!z+V*Vf7j|_}=wTlb1Eqpq7peP(*9*VSlqJD1 ze}&tCZ2mM`GDnL40I+q1r!(&pALZ@T2L#xVQ<8uoaAk}K5ZB+dIK@2|TP-PIUXi}- z_?4D8_e#`*6;S)WJ|%q#bqVMxy8DJ!KQCQ6xQ40$&78Ls-Oep)3c%i>zFnn#S*83$ zqv*6;4?y2)#!}Y_eoXr&JOFdabX)dHX<2RussmOMNhKojUQf(B{DJJTXa4|XYhh9B z=;)ptj4&;u>yEv^qYkoZciJIV$f7ncEz2dgcm4oK$x(u=^RSl)mFfhA1~!iF0k}nn zb(96ximnVI13M4!b+CKs+K;aXH5;#1DgA%(a7)*RXQHY8puwKtsGRbT++jcfv*&GD z=66??(qHmpWjz-wZYn8Jt8hE}slO7TYj}Uzk-%QJUozG%ERSktmE^X%a$I!o z#=l)~que!kZ&Kyq?B)*qDJjR?7WCGgLvQhc8YmRKwkWW8z=^TSOMZy zSk3cophk3PAUh?$57{0D(%%cc2f+a~MLk|GFYAdyg3XLOfkoBu#7j;#-cbDolmf(6 z4hqrdV=4D);;zYaeAmXX4f~1?mL5#!%{lWa zq09?`L93b$(e3P}|0(Yyo|sHoPbv64kp!(!}6w=XRxCJX_-EE%_^9 zKdDnkJjX0Zi+6d+^2)M`UbqPIEksL*G=uy^(D%fRC;4rGiw_5HxdKj~q#E$6zAXO90h^G%tu%7aYee-XRO! z1%qA~xSHgw=f_a~U6dI{ZN}r8g~Z%oZ;p(Np;|VkgKguZgJMgr>#xA)>tM!&7jG&8 zd^Z;xxPVKEPde)_3t&C5ZUjW3S7H^xMgXNxw*0Yakg%&@o$v#lh22|9kRx};;Dd>) z_AS-#K#i^MIO^9$QS0VD4h4i!j$La4%T`r70rA^;u2;B7(PL5kgVQOTr3&#ykFyUD z8z8(Au`2by=?zaZ!k%~l5sviljk<8veZ;cmfqRhfK{M+H$6^YIj&D_v_|QDCRb1g^ z5!`nu#(SBlk_f9p1r|z6_QZTwA=77(4gJH8;uHvrDQ2fa^|($hh0Smc_O(YLaHUrv z3J@2Ief6XQ?fzv;rrkGKkeBjc8c?9uTrM(ksV#TWEC6u1P{wic9GkYPwkfTuB@Bt7u*a7Ywlw&}Y*Q4gu0T$bG3N^wbekP&M-mSBs2*VixF4lThlkbb%dqf7| zv5y7}a0>DszNUXsdfSBab!30AcKL*F%8Lsi*vKd=l#ul+g6CaF#!VXV8EV+vxUF=I zqg-{aVo{07br$v94hVU@Hr!EcuOtSsEea?Ur$+$MZUCm<5B(brSwO9&^=>xUzG%UZ zHeEgOAF&!%xq{1jCYqJ7T(r2Q0S5tK?GRc!^3$0CF-ju9VD9)Yjha!WjSHs>n0kX~ z^mctp&ob+E)ar6@l(ln;p{QNsZY;c(br8r`tori;XjTtiHT1P*sEO<&Wjfvk>}cc> zuvP3T4iFUJa9iL{m@}UI*2XU@h4R8EQ{Ta{5o>7*a)RvF3=QYIIGNtd>B|2#%iU7sDZ&X5u6Cv5yH0 zn&$<(E2n~7lw1R0jJm&phw_Rb-MjH&+XWE%GZB7BmOczCK@IYqev40PR7cSK2T;$4n)qyUp_pg;$WmD!KQ$ZS6SOR-# zic35+aw{4O2Q7`tpkM^A=aM{%rYS;;gH3Fu+EEi6o<5-iuh9c8Q>d%&O*`&Ou`PcxcJG_q2&YxYB;^^k&} zCaw9X(nMI$y^o6`CW}E}SiaB+h(uoV7b#U$IQcL44G&x+?Es1^0N}S{8huyN2Ws-Z zPKfB#m+s0jvlr3@YMK!J0C}>ocmO$r7sV!8RtL%_zB!S5 z%1Z<=UkQWeDH2m~c|Ee{?5hUY8WXrLPJ;!x&|i~)MH(j~-=gpxV-+fH5xZ{6+l1~5 zpcrYql}lXr5gk;OUSFRaBidf}`d{Oa!p{wFs zVp>o}L19q&{-P&hQ@RWlcgEzzvF;m?Kn{z-AtwXUa51Aj(H7y30P`UHL;)W8L>6v% zeq;AgZ`?8A?M^%Ai;IWB(s?pY`s zEpwd-V(!kR%lAwTG#Q6BJT!rYEMwAr#fbxB==p_5t*6@s5q7@jZglDv%UWs;M7Zvf zr;Z>KRUWBBupnDTS06}*4w9N9<{yvd1j^xnp``KDK;Zd*cLes$fbkac zp9MwT73r320;|YhQk8x@L}09<)2{3b6P?Lt-p5L-?Z4_NYG^Bp>T29f*)Ag>mQU@f z9y`mq$~Hv_(kQcGSeUV+5kP+jo$pCjsvovneUyBK{>gq(is;hQ!`wVR*JrrRaCOAI z08<^!BqqkcKXGB5+s1+P7fyYDP(W_;HDBGrRYH^vugt89^$_!byu=9buNl#@svR|# zJnRtyEgO3npw{3vf{M3m!m+_%Ifx2A+mBzV15SCJA)8BB!1Yb-Jp4^BK7x{^DA~73 z#sk(^n}2emK^5~`3Ae&aA=Cq+p=EBYdSaLd5v=VYZ0A#u1}*@?-A30n4^XW5Wy{u8 z*IV|-9or-Bh&lO>wn*<(l-O9r79fJ+WQ zXuB+b61@sg4}!raG1)d2mb(RZS88y^n^B0 z+%=?wX!f$mywT7+(9!TiXtpgg?Erx_;5?$os9p0Im59|w9=H(qA9k~)TL5uJzkp-c z_;;P*OM|H)?Xt%y1jhErGmX?V-y=DYS|7(P8UFz45c;}f*`)b)6PcX?$>6i$3CUS- zNi0gKYg)j)&;z)huhVl*7L~W)#}tjd^Bz|H!rg@_6BKQP=S8}_;EP4xX!yRMGZjtR zZl??=8OYc9vo@X{mBeI*&5XN8fS2;2lNw)v?KR?FT3l#)w(GtLN$RQ>SFI6p63)Sq z8X?XUJWjy^&G1@})Ok=2uKKQJ8+?gUD7^~rm@YwLS0UxMRd~g;_nbb4UeTm8fFI}a z#aZbsnlvRHz0@6d`5`2zI}dGU>^8}*ekCnIR?82MxYb)d{L6PJA1LK(-ImlLD+dbn zihaub1k@bU=R$0l1zAouPGV{lAKwfzG#8p(5MC_UL(hQkjiME|T&S<HvA9I)S`uBKO~^auL!&s=NL6Nau&Ulx1fij;L%9ZyJHF0Lk zpO$Vddwlb-;2cb*4&?z0()AGagX87{h(!2y2aAEG_-*t-gRGC*Rtc%eDN~9yJH0>H zhoa5%?iegHf&k&{I2NqHwJ+>}be2*S`HgAIs|}Ea5^thQQhExrxN&W^k~%tLE@C0Y zu4a~Ma{O?5qEKsUShE!ersb@3b~BBxb-@K9Y?1vM%S|+ zQ~~|o%y?u!8zhG<*U%pS`Ky~@3n zT)74!C8MNG$b-k+PIr9fSS}&pX7Q+aJSJNb?}e3Z1|GX{J1pEQZ0ipAh2y|RedG;i z>C*@~JOT=!DXtnj^Lj(fS53}Vc%b}WxQ$s;Zi|Y{`G^)qx83OGRM{`fR%12TAqI1`PDHKGDqQYFB0WRjh7!`-hztzliKy*;V;qu8lX;GgS!rRah`NF3`>{;=?c)vcO~rvD8Y> z5WX;n*KZZ;Z`jRJSa^)cRCe8bN-t`ks0Ae+Va!CS06aqpp~1Ve-9zg0u>73IF7hYl z55Ng7k58PGf@*2OAHuJv#8xSweD@WG?11wNfx#U2%=k~dDAf>KX{-pjUtGd&(23*X zAAE8K(GeIc#q7~5HMgQvXywMLAgVW?{{SH18fi><;wz>F5T6v?_>algn)@gF&-mt*oUHbcVa;shwVp;Tjq z6wy9$KkRUo(N&uN05S~iI*L)Ht!mriCl+c)-a^=x)lCB4`Ze z>S^5vzB~NK%QOX2RbdT99khwjv^&w*tNl^d*P_B$xM2?-2=YNEOTND_*-1k;Z$6&M zXdgCw)l0nu8~IX+Ql0aDqq-R^c>bb{>UY#0KN-8kIDmZ>%O6OF)VB0WG|e|9bw)X! zlZ@x6=$()U+--WX{{Vypq}19U9L28bT#v&A{8%hwH=F?dArN?D ziD1?-xZXjM@qCd9xB`a{j^jfbis8!C2B{!)>PIH_0hW%Ld;4Xn;dCjsihRocJs4`> zbw|+SBnL57(4PFUPP!{kIWzMtCIQg#&-n-)>ERVz>FjoyzcN)j(a{zzjkbW5Xwlw# zfdFri&RG+9aOHf}^^%m$z;az*#d=7Z%T7>fSKY;?+1fC_sEDsA*F02SNXizUc!86f z7sxhP^cuTZKk$fcY^VF?DjRI52&hzU$Se4qVr+e1QCgQvu68gA%pgG3dkf|rDlt{< zXN%tL6OR6a+Ghyvo zt%+!l71TpuQuyX)+%I+^qxqaC>c5g@Fhw%U*F zZu`?(bA|N|GuvW31U;aEhVpxCg#k*f3+~m(Ju6#vqL*JJawf++{{V8o za<`a4U-3r3sU3;1>TG|7HTuP01AzcH-1UoJ)(yS`tCo->^HIYk7DrE_# zhOcGkgt{8OZWTB3_5~vf>TOAQE#z15p=0NHEmGPD&U`_2e?Vo&&g%JW)4GKW0IwB z%%A;7_h5TC+ zb!e>%-87#(WpOA|QrO!8(`ynw$y}%^rmt>Z z$QJlh0xt!r1yj!u=tcm(565xRJ9o|eM9%Z5bWFfDZ!rde=yjPJshYib*qbVR(&d6l zv>PoF0qK7oqbN-wM09Y8!tFVDBUs9~W#ENmk@d#_&`_4~rBX>lYUuu+r&bUOt2j^Y8*6d-*oKgx=@*@P zV%om;d((z{CR6}uoqpwlofPJUD>?Binldf3rDz)-BKmQ*if-Kc3z zLuv#~F1>Y8F}wk2BJ{#yEL3w-L@X7S{{SPFt5PiW;b?#t!Aw~ZuzcLCOZGyTrdL2} zH3H6DG|5?3z}EPIqSPNd(G9X9&OrdG+(@ZTes?}Y81Ky#R+3zMd;Qd*#?s}2V^JW3ej4lFoh_A6G?dkBieemFbT zJE- ztO}Q(xTOZs!{TwuCj?lVQG7Nqh_XJ|_e)MYmP>302?R}ApX~ROp_E(MQOTM<1+;t* zahht!HE+398e2bH+b_mS3K#$|C_5{S>C*N+Pn?0Am&|h&Ax}V82T!s*{3+=X)3?eiSKX@N~1>Y5lLbUQ^V0z8Yt>j+@}(Z$d1pwhmDV|P2lqV`)?!#FJ= zc3;%3sSC9`p21UAQqPIhg*|>--x22BI6=qmWO#0~iZ9(k5S3ko2M2AJlGo5NUpKdk zdX;ox`w{XMe&t`9fE=#*M_l-*9s+JSD}=@k6v?6%q&mI86fb(XD!?i8Hv*+W6aL3N zrT(H7^KqP~0A>%ahawtF z8R?*5q3#J9x5~lf!-eC-$P`-w1@}L^PPnMp3Kh(-|IhmBlIpgUYUU%?ZK-ko4nydgDlq~e_;jomrKmWu4DiHtz0s;a71OfsA0|5X40003300R*c1R)az5F#-YG9V)`K@}u3 zLjT$T2mt~C0R;f=WX!fZoy?is$(d=EnQ7e1OtkK8HZUB_nX#K0v74E(z-h6YnX#Kq zjM^8Q;~f_Tb{gC#`JOdOtx%76?jkPaXKfD;-| z9McC$q|$08vq%zYG@4B&pXMeT7!+OML55HjRD~O{6@-IEU)BfgAK*m5n1E(rLITVj zVqi~*{{Smm1k1D>eyXKFddxM9fUc!*O$IW$jj{zckTysgKg(H(uFWz2R&7W2*eO7< z?ntQasI}G)Yj0;Ss;ODx3~<6dO3>6&7X<6to`>))iIEtA0m1(OCU7kcbPM4PTD5c{ z*D`fwt<6ak5HK;aOmxQ}W-W?V%>=1-HYr#&AVS0d@SOhu1t$Px1wQ!@pK!thpKBWd zh)9g)S%C)51#Zw&QmWuVE?K%PRp_>-Wf4dy;jnf>ZSt*ZU=Wgjon!%#GcsXdCgN-$ za|6Ib2Dk}JtGff@-Q8jU!*Nr@*@JBDu7YLEu7Uy zUShOcLrtJFFn8~PHdt+37hG?Nyz+JBi)hAL*Qsn)YeL4|A|S30i^_$)S_TFJ49J0?p+xblPB<{}XMpF9N=mMdL)w8vEFLyYuCOI+ zvm!Z(g#>iY11oMn+At8X)Hs%R5aD_u7%awTA%Con!hj({Wod5fOQLqLFrHNezGr0C z2%2UT5o^S2G#oLy)G->-T=H!ZN`S)*#~CAp zcT`)h&9$+deOA6pY`DSpQ;6u(7!?CNVP%Q~zd<%x!nIYabt2%?ZU_wAF;bPbiw^)`YA_+%af&d(#b#gq_92C0=cTk^n5zbRA1y2?BmjRqW zl7`grTrwkM7s2LQPbQk*G`k2%jCO*Z#}bbmIm2Kz*TlHWZO8}O%xZdPe;wy7IY9WX zIJ&c#8v?>iJrlH3iLobh6SC~gyezdso^aHM(@}R!GcdvHUc4HIONX&5TFo{ z0|7E?KAp(fc@9|@A-yu7oJPosb1a-dG$d-zm}8`(zQW;AW(T#gKy;x6FK9kKM|P)D zr7t`t6vPV^3rrcWK}4#)zBpysZK^OJ1y_ut9D6)$c@JHok?Zu#o*4rXryWYw{{YT$ zG9i_HtWZa@8DZ+S!g`(eSL&R*6meam44yR~20|m(m@C8@!-)CS2r%*yC+3hA{!77N zK+c9`GDyofQ^Txs>#+Vc{+Mw?A7*SZvyW>bPmZ;5i4aX7(y7v z9U#k+3SZEFzDgCNIF;OLiEc)6Dni99T2}Xa91KO+pBQCY&P_y1JfdxJ6>*uT9-S4A zR{qw7>ch#k{WFYGtV>jx*oY-66s?#6-KA@o6-Tj91Pu* zG^fjvP*C)Zwb@rS8eC*pj2+3CFfUfAC3JA~gEMtxavuiiTS`o?F^S4_qp-O!N0--= zXw;LDULrV2%eY>nGM4BQ5a86}TD#c0)GKlt`|^eQQyoE?vQ9c8h7%Yg5~Sad)i9er z1;K-WA{bY?FV=DFR#swJV*a2L>_$byu1Ui-c0{qEu>9h@kvh1hp*f$f5j3RUB|h60g> zX~V&wk9#Vuz!fA?k+96QlQe+h2M|~QYZMMgsL@EblgQ;7s>Dk70T!WsgpLXyFRD@; zg$6QpTWFMBUbfQael6H)2xOpPc<5lELn^AvNf#^b+u{Qp;W!PRE+%`J*d;;*!11X# zurplhd@ThQsN{3&q}j{9_=p6ifBu017-d#{!Q2@Ujh3=;@}q*FD)`xKTyDAFFd2SA@s}|M6#b{)DzM2}4qk;Y ztE(5pNAJqGxt+>!?81du5V=5EFKw*OlNn!3>xqcp`Y-}xvJ5MO2S+4I=n*T_R`~&P z3I#aC0}ucwCUMu|n8V`>EnkZU<8pXvmeX}Tw-vT2hR}wsa5b>1@f^xHGk)adtvC&ZB@&DQY2mt~C20sA*0F#SB1}_%J-ohpZ zOGFqUnJ`46C+^y(EQX-niQAc+n-Gz?3fRxw$qB)cq+#0z(uk)EwYhgWj18Q_>O$2& zV3K%e1tU`jspx%2)MrLXL{wmIoylmO{G54kC`6+_gV@2GkwUgv6y(D>kto2^MACns z_BvTF?3m|tNVqp9x~2Yzq6aLQB*B+xSRF4X2_{eQI*A&eGksA`S;0nMSKH9x+?1gc z5KIwKD2SOkRtH^28@BO$TR0g}hGd3Ly^P-oltVpj$5NWRb|#|^{)SEzK+IZW1TY|dZbd`18ygVx zM_V{0J&X;baiQl^m8L9>v^zl>v3E=y*&Bhz)|#?3$D(Z2u!m@GLt19ihvY#Bz{Ezl zeJ<^iEt^$J?nxRXk0n0Llr%I!f^Ca7K<8H}4=U4M5d2RHr-w2s^fD0qnH?{5F_^SX zqeC+9B-;cHH7I?Ul|6JjV$L)>J3mq6Z$@p66KoSmQ@GXVvsP^m^6bXlvRH1&4L7MnoEtq6$4vv?XLN~G2dJ3lR%8+LT}8hRSCrF2A*X2@z{{I_y1 zi#Cih8r1bQ*mjXZBKfrUIBHgl#!RW>r)69pa(Yepe4%jZ#fSB3{Czufy#B09`F3Y{1ndKA0h92|1qRQ-R6+Y2=>h z!HXuk-Wio_BYjfLW%?Pyw#3v$B9?z5-EZw^9Gnc~WAz=OjgFyI$V6GmN41@%`Lq~B z!3!*jgkZ`ficWD+NZ90sv)IVV>`k!Ad6CsthYHTr+6F;7Tp_1aBXf5Bj#?z_t%0I$ z>0?z4X{%&}$4fHgYoUWH1IPEZpn*62%d>+vo8r>Q6-H6Emc@3?DqWvzH?Y|#mhH=d z($-3qBn`5iIVRY&k~HlaTeR7YEsSndWND$nENK}fEPvUM*Fh16sTvz^2aj|oEW)Z%u*PlxmHgoZLL8n9rCXF|Sg(9tQGK9U;PjuI1u zWzL5^3cPB*J+(2Dqu52Dkt?gE%bUCjmGvxCy{Of_m0^#PzK8tl%L*Jz{$2 zt__%WAmm{>Aepl{PE(ZG`e^+-6SxOm6&f&s7xF;X+k0?%Ph|FLa}YMh%-NeWW^$aT zDavvaXZz?zfa(X(4LSB0Px=T5-w+t=F@=@@tg$WAwvS6 z?a+Xq>-dRLXaXenZe4Q|U7*Bm&gv@2B018yoIrxg#2j1|A}R0vB=xiXj-corYw21C zno6biv6a_ihOi-HZLvPqq=2K>$hN?=C|3;@=U6Qd0v&CD?8P7H)H~nz)YnR+%7Cdu zdx}`%+eb0L0)o|B7zo4?yr-@BsnM(yCrt%xtxq5w%B^e~0>k}452#&@3B*n!aGE3N zgJnMUv(_bh(XcFQP)}v9&9XY(4K-no=AoRB*Qytz(wrwEdy064J4egD>WxsldEoyMB*L2ul=J;G@EN;!&x@#mX{!SBCXg;DW$3__xwQ)|w(3<-C^%V}vMXp$!MY&ui4d&B+tF73y z*|EVkmQ_gBvXbm#D?3t^)=j1^;xdt^1;(O077cLc5lS7xG*w8LnH&JH5$U(?Sb(1N7GFE%V#RSe3Xb>xj%NAn8=1rj$ENROl%(q}V!Q3Ye+UO&&av(_w3U z`sF!Ue;uOIK{Xbdd8lUPM5Bz622c=QhBK>IWqixj z=`pf6o$^Y^<7LJihJww?^#~h3wj*lHs}J%QMh4s?>>} zx4f&vydE=*9xIV=5JVm^6l7r6E~Kmr#Qm{`ku9tbVl?4f!&BBUoLObK32NPe>1qy# z6Frp_Yt&48DRnkfwgwtevs;yXkWIDm(x-J{lFQ@D6O0lj8yR1XP`|;gLgQCl^eP0c zgFZ_svnkN~T!*s8GTLT!Mz$bB59o@_4P-?mO6-chx2DBvyp5`&w;~}NLLx)NK-GwA zM~0gaQDdP`Qqih#FWr7l!6>t)$tpr_8q6w3(za;e5RdjD-8GhTf=2O( zG@j+Sz}t*|+B%%r#R?%ORR&ifNwEZa)n+?!3B-Lbuaf3g!I)|y?BY~K7CP$01}NEe z-8i>!neJ?{EwHr|4^=c6)UO*VAn}8!gJWDD5kmz6wZSSZ!ClZsigr;#N`WKRx^?S{ ziq!_ng@sQaO3zE}Mxs8VsXF~mcqujChQtk?;GZ^Eb)FvnKODSNkZPcoE9`t zLn`Plp(9q1OMwKhrpQsOO9HlP1#CmRJb6Bs18R$~o0KS3V%mirQ9napCbW=^cvx+K z@(QX{60m__@>qkHhfh{b8AsdlyxTK;+VmoITlZ}w2!oN)iqh){1&rL;i_y4!l^r2} zYS2Nq3u%r=OhU@-`>S9DmUqn&gOfSv`%4UqG&opIY>6qogjA@@Yih#SWBl_-U*616g z4id3iey1wZwUTy-z;Y3sXz?&=C))}o%}?0$h=)nwZLY&=hK)$F8j4Equ?5*KQR{n~ z4;d>$PMED|uE`4kITjQOgi5KFx>K{}a@xl(dY z2q&l3;aa4!)|T4<9J=IV#_W!w$_&V_LadDn zM<_<8#p*`$89|cwI|{`mY*_~oopHv|gnieJ4@=i8#`f2Zge;fUVjFo0Zpo<$fN)i? z=?=@V79j*DQ5Url{{UAVeF}{csmJM2?CFBy4mh>wSd$MZ;N|7rRzbBvCMoP4ODzjL ze%h-oVV9J^SA&plG-Rl&*eik4CcK(Y7pdzjSP-%ZmIi{riD$aIS0IU1(!0PF*pD1b z7qj#~gU}s)D~@!yhuE~I2*Yv4=Y_GWvB-`)8i4Xkt0rRFYNEGW9_oRZxFIdC4NzaV ziF2cWEpA-fuD&603v!i>Pb+w%$5&?HuBL*Lfi`FAQzpO(OHGPFL_AMG_4Tiy0cG*y zM34p96;?l#ESPyByyj!eeFG^yKN5L?8z?1}WUfl5I+~jn<84X8n%QMl)NZ*y;&yc{ zsb4AbY^_v*OzBpu1QnMWI&xAY^Mcmmmec_nj3WIORoJ7Ia{R{8=VTTNlq;opStBz9 zSrxfUa*a~Vv+Vbi2xn|+uT&M}HK|3xMNE_+b1p7|tGVItQ;7u_1@lhNFC zavV-IZd*Rse1*VcRpfIbVx>`=V(XHyZn=O>G0PQJ-C#!dE68k1D9B8Tk@QvciE6R;y-knu z8|qg1Bc#8|?T2$H{Rkb#xVm4~RAc(X@_Jnm4OMAKJgZ@$Nq`92okzUKXKAH~VQt0M%W($H%wQPW z{{VloP{tq90ec99_oe}`&3;G5W}C6E#mX9^Jpxm5us7Gl+sZw)2D3a&ywGd$cny^L zJJ?hfgLZ9(+>!V_R-c-mQpD3K;L3I^17a5%EwK)Rr{n`1>IUl%v3qI5bz2b4V2X5p zHJTRO+$yrGvkxl_SC+z2e`Pm$b9?t3u2H{DoJi=b|>Dqm=;4VaP#ZTbTd zhM!GbsK@TOSWSR7Tn#dfx`INM6GY5H1hn!$kQnH{+6kDs4_o5o*Pn~SLnp;mWd8sd z)suy2E8`rByk0sSY;{Nzp`k3a1#;FOkmM~j^%V7Q>OUwB={r|m1x}*3PQP&^F&Y7h zGQQ0G6dmJ$3wQKADE|P~jCe2S`%H3a;Bt8>glecsA|oB<2)3$8c9L5+Jf=|uaLN5WjAOxrB(b{oHN1U> zrcKC`4h<%7Wr~`4G+tQaw-qZkSRo={#$(V)FuNfk?x zHY;IS;Bt2)f@;`sh-MiN2d$irp;VkS@GnfAO6$ZzyDA!YpA!>AJ_IDp-xq4h8c~cd zQ!Za)O=(949D9

PXPjUUjg#wH3!HJF=!v>Ha2CeMZ_(v?)+hs_uvzV}?s+7~u6A zMtLEjxX9ggK{Uf;(eoJu+Mc{)k@cuTfB3JY+6Y86YFCdGTdDXe_Ufr#l`py#9ZWS; zN5o@DZMY>?he~oqcw(tJfwDxKA}}>Ej3v3Nrw*0FaqsaTOQxJ}JX7w-aNnWjM2<}~ z?)E>*;|ET+{{U4#+q^Rtf>N{aN-#l3UXAH#ly&KHtA@U8luIi7rG!dS+LXJoy6JS( zl&Y2xs;@7<+?rgYPkSwUCY}dVS(ozQny}X?!0V|??l(;$-)52@(W@_zuq{#!m2qcSiYV4Z%IkVHS>2%ET_At`;H3#`?Z`&VM zeos!6lI%F(qu5M>8k$K%aU+!knPv={so%M(g)N;ftEy^F9if^`4@r^7g9MI9R%Lw} zw@*_39irQiL51LiDrXFd*vaG`W(*J}er*rTFT>E~Qsw*j84l3|cp(wok}aaGnrLdy zUGO9%#Y!}g$3f;WF|}wgIzJCXPrLgLs+_iB@APKP+G=}J+A3cgNc{(C>?BBA}RNo1)y1V?yH!@0Q2l@$+F{1;bs;3xTi6+pkp{$Ly4GeCMPd!-@N)ao9 zYTSzULeZ8vc3ZJG1@#b zL*#kN!~UJIXm(a;W0Hqn>kr)!_7LF1AceT9e1Ah!m3L;or0GxQM+kGdJTL1db$Z#7 zMf2j+{l0q+(5~*&wOtnRTF1KkqeXb^`whgU$mCfv#@ihzj+&Om)$3I+w+5AqG|Kv~ z+wM2s%i73np9TyPI$k86>puH4d$HbZoh};KT}96RkCZgf-6#8f38jhaCFPz}Wm%ES z7vZVn`R2~qGBiW9S_!>NiT+gDEl}?sg!UvGXvaLdVmA5Pu-c2`yJ z>fT>h*ZTUQW$)F0*Z%GUxTL&ntpETyIR*d%004jjWCTM4(Ers4|CRqBrmU zsmCIrqV%8V?*#xu(%l^F{jWFdzqa&0*Cl{S)yB!q>8p*CGZ`lf8-Pz-P6?L!zwUs~ z|HuAc=I<2%P0Yf{%o1QR@XrnPcM}i_fQ9)F|12y#?7#8}4i*;f69PQ^CwO=SBm~5N zKtMu5`49grDk=&J#(%*6UqHda!6AG?Ku1DC$3{a#`(OY6cjfN@00RMv3yKQ{3IhO* z0R@8r^>-Lh|BoLW)Ia<$e>mv>=s_U>5TO9jF#pe|e@$380Q@I}&j4sB7#L_+7s z_zE|sA`u5l#=~zYMuRIVdLC(P ziuf}<@U0~=;d)8T+~*Qj9H~^J-_%D-M9e{j2mnv zMa7{fNjX(|#+bspD*4Pe788Q>O({~`tv&shErV(@q3+mq)mS+@fOcAK zybdl2$U|x&mq?}rpb9!tU0aSJ%?`a4s1s^rK>asCSYK|7jELCte_=|tgAMF-d}i0+ z&Onl%n&w0qK1ozGh|px@M7ElcBz23KtBy3q(oYrNzV@=IKn{mRd=IGYx?KU7zQu_2KUEYp6qt#l%efxqw zB~zb7ve9@l$8(?MbM0uFzW}->@|d079J2ATWf?8ufXO?zsj@PCzq}A-dx^A~DYp z8gJ^zgPWf^Tmw0h(9`lqgYT6{K+?l|rqVj4LAk}`Mb4w=L_~=NtA=2nt3LQNCZ)3} zCe)Bo>4ZuH*NsqcQUJ{|Vwx9uWp@-T8vB=@2{uE(9~whn9iKk9225xp@+_n?l1+-i zl~9QNkxMW~N>zymLs&1_H166A02?G6H3S-&a`E8$1ByjW9i%xAG?pZ$9u zVBhA?6wPTxQ>P|c>M(^xe_cVr?3>qW_DOHyf@7&EroPJIyD^+QjYqlz zeR7>{O2(HGK6v>Pp^RNpupE9)o)!EBIAC^@&%+&z8=STaNUy&A%t{8~tb^9h#11bQqa0Oas|z=%)8uS!?=6br{)rls}kqwgVC^T?p$sx@bejX&{!I=U)EL7 z7)u3Ato8CsTT%Qt8Lq07^#!_MAUViC7~XSU8?T!x`gH(L9lkEKbY&3wWUY6%wwgkB zZhhrp3ep~goEY`P`O}6!^9o|e5vBV16~`y}3)Uc>AqX?vH&�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 21/26] 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 22/26] 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 23/26] 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 24/26] 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 0bb37d4991e12dce6077487ffe76d15b04d41480 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Tue, 5 Jun 2018 13:41:17 +0100 Subject: [PATCH 25/26] 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 26/26] 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")}