diff --git a/src/Umbraco.Web.UI.Client/gulp/config.js b/src/Umbraco.Web.UI.Client/gulp/config.js index 4dae0dac08..cf0407b71d 100755 --- a/src/Umbraco.Web.UI.Client/gulp/config.js +++ b/src/Umbraco.Web.UI.Client/gulp/config.js @@ -16,13 +16,31 @@ module.exports = { //processed in the js task js: { preview: { files: ["./src/preview/**/*.js"], out: "umbraco.preview.js" }, - installer: { files: ["./src/installer/**/*.js"], out: "umbraco.installer.js" }, - controllers: { files: ["./src/{views,controllers}/**/*.controller.js"], out: "umbraco.controllers.js" }, - directives: { files: ["./src/common/directives/**/*.js"], out: "umbraco.directives.js" }, + installer: { files: ["./src/installer/**/*.js"], out: "umbraco.installer.js" }, filters: { files: ["./src/common/filters/**/*.js"], out: "umbraco.filters.js" }, resources: { files: ["./src/common/resources/**/*.js"], out: "umbraco.resources.js" }, services: { files: ["./src/common/services/**/*.js"], out: "umbraco.services.js" }, - security: { files: ["./src/common/interceptors/**/*.js"], out: "umbraco.interceptors.js" } + security: { files: ["./src/common/interceptors/**/*.js"], out: "umbraco.interceptors.js" }, + + //the controllers for views + controllers: { + files: [ + "./src/views/**/*.controller.js", + "./src/*.controller.js" + ], out: "umbraco.controllers.js" + }, + + //directives/components + // - any JS file found in common / directives or common/ components + // - any JS file found inside views that has the suffix .directive.js or .component.js + directives: { + files: [ + "./src/common/directives/_module.js", + "./src/{common/directives,common/components}/**/*.js", + "./src/{views}/**/*.{directive,component}.js" + ], + out: "umbraco.directives.js" + }, }, //selectors for copying all views into the build @@ -34,7 +52,7 @@ module.exports = { //globs for file-watching globs:{ - views: "./src/views/**/*.html", + views: ["./src/views/**/*.html", "./src/common/directives/**/*.html", "./src/common/components/**/*.html" ], less: "./src/less/**/*.less", js: "./src/*.js", lib: "./lib/**/*", diff --git a/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js b/src/Umbraco.Web.UI.Client/src/main.controller.js similarity index 100% rename from src/Umbraco.Web.UI.Client/src/controllers/main.controller.js rename to src/Umbraco.Web.UI.Client/src/main.controller.js diff --git a/src/Umbraco.Web.UI.Client/src/controllers/navigation.controller.js b/src/Umbraco.Web.UI.Client/src/navigation.controller.js similarity index 100% rename from src/Umbraco.Web.UI.Client/src/controllers/navigation.controller.js rename to src/Umbraco.Web.UI.Client/src/navigation.controller.js diff --git a/src/Umbraco.Web.UI/Umbraco/js/main.controller.js b/src/Umbraco.Web.UI/Umbraco/js/main.controller.js new file mode 100644 index 0000000000..654bbb1d03 --- /dev/null +++ b/src/Umbraco.Web.UI/Umbraco/js/main.controller.js @@ -0,0 +1,209 @@ + +/** + * @ngdoc controller + * @name Umbraco.MainController + * @function + * + * @description + * The main application controller + * + */ +function MainController($scope, $location, appState, treeService, notificationsService, + userService, historyService, updateChecker, navigationService, eventsService, + tmhDynamicLocale, localStorageService, editorService, overlayService) { + + //the null is important because we do an explicit bool check on this in the view + $scope.authenticated = null; + $scope.touchDevice = appState.getGlobalState("touchDevice"); + $scope.infiniteMode = false; + $scope.overlay = {}; + $scope.drawer = {}; + $scope.search = {}; + $scope.login = {}; + $scope.tabbingActive = false; + + // There are a number of ways to detect when a focus state should be shown when using the tab key and this seems to be the simplest solution. + // For more information about this approach, see https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 + function handleFirstTab(evt) { + if (evt.keyCode === 9) { + $scope.tabbingActive = true; + $scope.$digest(); + window.removeEventListener('keydown', handleFirstTab); + window.addEventListener('mousedown', disableTabbingActive); + } + } + + function disableTabbingActive(evt) { + $scope.tabbingActive = false; + $scope.$digest(); + window.removeEventListener('mousedown', disableTabbingActive); + window.addEventListener("keydown", handleFirstTab); + } + + window.addEventListener("keydown", handleFirstTab); + + + $scope.removeNotification = function (index) { + notificationsService.remove(index); + }; + + $scope.closeSearch = function() { + appState.setSearchState("show", false); + }; + + $scope.showLoginScreen = function(isTimedOut) { + $scope.login.isTimedOut = isTimedOut; + $scope.login.show = true; + }; + + $scope.hideLoginScreen = function() { + $scope.login.show = false; + }; + + var evts = []; + + //when a user logs out or timesout + evts.push(eventsService.on("app.notAuthenticated", function (evt, data) { + $scope.authenticated = null; + $scope.user = null; + const isTimedOut = data && data.isTimedOut ? true : false; + $scope.showLoginScreen(isTimedOut); + })); + + evts.push(eventsService.on("app.userRefresh", function(evt) { + userService.refreshCurrentUser().then(function(data) { + $scope.user = data; + + //Load locale file + if ($scope.user.locale) { + tmhDynamicLocale.set($scope.user.locale); + } + }); + })); + + //when the app is ready/user is logged in, setup the data + evts.push(eventsService.on("app.ready", function (evt, data) { + + $scope.authenticated = data.authenticated; + $scope.user = data.user; + + updateChecker.check().then(function (update) { + if (update && update !== "null") { + if (update.type !== "None") { + var notification = { + headline: "Update available", + message: "Click to download", + sticky: true, + type: "info", + url: update.url + }; + notificationsService.add(notification); + } + } + }); + + //if the user has changed we need to redirect to the root so they don't try to continue editing the + //last item in the URL (NOTE: the user id can equal zero, so we cannot just do !data.lastUserId since that will resolve to true) + if (data.lastUserId !== undefined && data.lastUserId !== null && data.lastUserId !== data.user.id) { + + var section = appState.getSectionState("currentSection"); + if (section) { + //if there's a section already assigned, reload it so the tree is cleared + navigationService.reloadSection(section); + } + + $location.path("/").search(""); + historyService.removeAll(); + treeService.clearCache(); + editorService.closeAll(); + overlayService.close(); + + //if the user changed, clearout local storage too - could contain sensitive data + localStorageService.clearAll(); + } + + //if this is a new login (i.e. the user entered credentials), then clear out local storage - could contain sensitive data + if (data.loginType === "credentials") { + localStorageService.clearAll(); + } + + //Load locale file + if ($scope.user.locale) { + tmhDynamicLocale.set($scope.user.locale); + } + + })); + + // events for search + evts.push(eventsService.on("appState.searchState.changed", function (e, args) { + if (args.key === "show") { + $scope.search.show = args.value; + } + })); + + // events for drawer + // manage the help dialog by subscribing to the showHelp appState + evts.push(eventsService.on("appState.drawerState.changed", function (e, args) { + // set view + if (args.key === "view") { + $scope.drawer.view = args.value; + } + // set custom model + if (args.key === "model") { + $scope.drawer.model = args.value; + } + // show / hide drawer + if (args.key === "showDrawer") { + $scope.drawer.show = args.value; + } + })); + + // events for overlays + evts.push(eventsService.on("appState.overlay", function (name, args) { + $scope.overlay = args; + })); + + // events for tours + evts.push(eventsService.on("appState.tour.start", function (name, args) { + $scope.tour = args; + $scope.tour.show = true; + })); + + evts.push(eventsService.on("appState.tour.end", function () { + $scope.tour = null; + })); + + evts.push(eventsService.on("appState.tour.complete", function () { + $scope.tour = null; + })); + + // events for backdrop + evts.push(eventsService.on("appState.backdrop", function (name, args) { + $scope.backdrop = args; + })); + + // event for infinite editors + evts.push(eventsService.on("appState.editors.open", function (name, args) { + $scope.infiniteMode = args && args.editors.length > 0 ? true : false; + })); + + evts.push(eventsService.on("appState.editors.close", function (name, args) { + $scope.infiniteMode = args && args.editors.length > 0 ? true : false; + })); + + //ensure to unregister from all events! + $scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); + +} + + +//register it +angular.module('umbraco').controller("Umbraco.MainController", MainController). + config(function (tmhDynamicLocaleProvider) { + //Set url for locale files + tmhDynamicLocaleProvider.localeLocationPattern('lib/angular-i18n/angular-locale_{{locale}}.js'); + }); diff --git a/src/Umbraco.Web.UI/Umbraco/js/navigation.controller.js b/src/Umbraco.Web.UI/Umbraco/js/navigation.controller.js new file mode 100644 index 0000000000..e4c94f3c66 --- /dev/null +++ b/src/Umbraco.Web.UI/Umbraco/js/navigation.controller.js @@ -0,0 +1,517 @@ + +/** + * @ngdoc controller + * @name Umbraco.NavigationController + * @function + * + * @description + * Handles the section area of the app + * + * @param {navigationService} navigationService A reference to the navigationService + */ +function NavigationController($scope, $rootScope, $location, $log, $q, $routeParams, $timeout, $cookies, treeService, appState, navigationService, keyboardService, historyService, eventsService, angularHelper, languageResource, contentResource, editorState) { + + //this is used to trigger the tree to start loading once everything is ready + var treeInitPromise = $q.defer(); + + $scope.treeApi = {}; + + //Bind to the main tree events + $scope.onTreeInit = function () { + + $scope.treeApi.callbacks.treeNodeExpanded(nodeExpandedHandler); + + //when a tree is loaded into a section, we need to put it into appState + $scope.treeApi.callbacks.treeLoaded(function (args) { + appState.setTreeState("currentRootNode", args.tree); + }); + + //when a tree node is synced this event will fire, this allows us to set the currentNode + $scope.treeApi.callbacks.treeSynced(function (args) { + + if (args.activate === undefined || args.activate === true) { + //set the current selected node + appState.setTreeState("selectedNode", args.node); + //when a node is activated, this is the same as clicking it and we need to set the + //current menu item to be this node as well. + //appState.setMenuState("currentNode", args.node);// Niels: No, we are setting it from the dialog. + } + }); + + //this reacts to the options item in the tree + $scope.treeApi.callbacks.treeOptionsClick(function (args) { + args.event.stopPropagation(); + args.event.preventDefault(); + + //Set the current action node (this is not the same as the current selected node!) + //appState.setMenuState("currentNode", args.node);// Niels: No, we are setting it from the dialog. + + if (args.event && args.event.altKey) { + args.skipDefault = true; + } + + navigationService.showMenu(args); + }); + + $scope.treeApi.callbacks.treeNodeAltSelect(function (args) { + args.event.stopPropagation(); + args.event.preventDefault(); + + args.skipDefault = true; + navigationService.showMenu(args); + }); + + //this reacts to tree items themselves being clicked + //the tree directive should not contain any handling, simply just bubble events + $scope.treeApi.callbacks.treeNodeSelect(function (args) { + var n = args.node; + args.event.stopPropagation(); + args.event.preventDefault(); + + if (n.metaData && n.metaData["jsClickCallback"] && angular.isString(n.metaData["jsClickCallback"]) && n.metaData["jsClickCallback"] !== "") { + //this is a legacy tree node! + var jsPrefix = "javascript:"; + var js; + if (n.metaData["jsClickCallback"].startsWith(jsPrefix)) { + js = n.metaData["jsClickCallback"].substr(jsPrefix.length); + } + else { + js = n.metaData["jsClickCallback"]; + } + try { + var func = eval(js); + //this is normally not necessary since the eval above should execute the method and will return nothing. + if (func != null && (typeof func === "function")) { + func.call(); + } + } + catch (ex) { + $log.error("Error evaluating js callback from legacy tree node: " + ex); + } + } + else if (n.routePath) { + //add action to the history service + historyService.add({ name: n.name, link: n.routePath, icon: n.icon }); + + //put this node into the tree state + appState.setTreeState("selectedNode", args.node); + //when a node is clicked we also need to set the active menu node to this node + //appState.setMenuState("currentNode", args.node); + + //not legacy, lets just set the route value and clear the query string if there is one. + $location.path(n.routePath); + navigationService.clearSearch(); + } + else if (n.section) { + $location.path(n.section); + navigationService.clearSearch(); + } + + navigationService.hideNavigation(); + }); + + return treeInitPromise.promise; + } + + //set up our scope vars + $scope.showContextMenuDialog = false; + $scope.showContextMenu = false; + $scope.showSearchResults = false; + $scope.menuDialogTitle = null; + $scope.menuActions = []; + $scope.menuNode = null; + $scope.languages = []; + $scope.selectedLanguage = {}; + $scope.page = {}; + $scope.page.languageSelectorIsOpen = false; + + $scope.currentSection = null; + $scope.customTreeParams = null; + $scope.treeCacheKey = "_"; + $scope.showNavigation = appState.getGlobalState("showNavigation"); + // tracks all expanded paths so when the language is switched we can resync it with the already loaded paths + var expandedPaths = []; + + //trigger search with a hotkey: + keyboardService.bind("ctrl+shift+s", function () { + navigationService.showSearch(); + }); + + //// TODO: remove this it's not a thing + //$scope.selectedId = navigationService.currentId; + + var isInit = false; + var evts = []; + + //Listen for global state changes + evts.push(eventsService.on("appState.globalState.changed", function (e, args) { + if (args.key === "showNavigation") { + $scope.showNavigation = args.value; + } + })); + + //Listen for menu state changes + evts.push(eventsService.on("appState.menuState.changed", function (e, args) { + if (args.key === "showMenuDialog") { + $scope.showContextMenuDialog = args.value; + } + if (args.key === "dialogTemplateUrl") { + $scope.dialogTemplateUrl = args.value; + } + if (args.key === "showMenu") { + $scope.showContextMenu = args.value; + } + if (args.key === "dialogTitle") { + $scope.menuDialogTitle = args.value; + } + if (args.key === "menuActions") { + $scope.menuActions = args.value; + } + if (args.key === "currentNode") { + $scope.menuNode = args.value; + } + })); + + //Listen for tree state changes + evts.push(eventsService.on("appState.treeState.changed", function (e, args) { + if (args.key === "currentRootNode") { + + //if the changed state is the currentRootNode, determine if this is a full screen app + if (args.value.root && args.value.root.containsTrees === false) { + $rootScope.emptySection = true; + } + else { + $rootScope.emptySection = false; + } + } + + })); + + //Listen for section state changes + evts.push(eventsService.on("appState.sectionState.changed", function (e, args) { + + //section changed + if (args.key === "currentSection" && $scope.currentSection != args.value) { + //before loading the main tree we need to ensure that the nav is ready + navigationService.waitForNavReady().then(() => { + $scope.currentSection = args.value; + //load the tree + configureTreeAndLanguages(); + $scope.treeApi.load({ section: $scope.currentSection, customTreeParams: $scope.customTreeParams, cacheKey: $scope.treeCacheKey }); + }); + } + + //show/hide search results + if (args.key === "showSearchResults") { + $scope.showSearchResults = args.value; + } + + })); + + // Listen for language updates + evts.push(eventsService.on("editors.languages.languageDeleted", function (e, args) { + loadLanguages().then(function (languages) { + $scope.languages = languages; + }); + })); + + //Emitted when a language is created or an existing one saved/edited + evts.push(eventsService.on("editors.languages.languageSaved", function (e, args) { + if(args.isNew){ + //A new language has been created - reload languages for tree + loadLanguages().then(function (languages) { + $scope.languages = languages; + }); + } + else if(args.language.isDefault){ + //A language was saved and was set to be the new default (refresh the tree, so its at the top) + loadLanguages().then(function (languages) { + $scope.languages = languages; + }); + } + })); + + //when a user logs out or timesout + evts.push(eventsService.on("app.notAuthenticated", function () { + $scope.authenticated = false; + })); + + //when the application is ready and the user is authorized, setup the data + //this will occur anytime a new user logs in! + evts.push(eventsService.on("app.ready", function (evt, data) { + $scope.authenticated = true; + ensureInit(); + })); + + // event for infinite editors + evts.push(eventsService.on("appState.editors.open", function (name, args) { + $scope.infiniteMode = args && args.editors.length > 0 ? true : false; + })); + + evts.push(eventsService.on("appState.editors.close", function (name, args) { + $scope.infiniteMode = args && args.editors.length > 0 ? true : false; + })); + + evts.push(eventsService.on("treeService.removeNode", function (e, args) { + //check to see if the current page has been removed + + var currentEditorState = editorState.getCurrent() + if (currentEditorState && currentEditorState.id.toString() === args.node.id.toString()) { + //current page is loaded, so navigate to root + var section = appState.getSectionState("currentSection"); + $location.path("/" + section); + } + })); + + + + + /** + * Based on the current state of the application, this configures the scope variables that control the main tree and language drop down + */ + function configureTreeAndLanguages() { + + //create the custom query string param for this tree, this is currently only relevant for content + if ($scope.currentSection === "content") { + + //must use $location here because $routeParams isn't available until after the route change + var mainCulture = $location.search().mculture; + //select the current language if set in the query string + if (mainCulture && $scope.languages && $scope.languages.length > 1) { + var found = _.find($scope.languages, function (l) { + return l.culture.toLowerCase() === mainCulture.toLowerCase(); + }); + if (found) { + //set the route param + found.active = true; + $scope.selectedLanguage = found; + } + } + + var queryParams = {}; + if ($scope.selectedLanguage && $scope.selectedLanguage.culture) { + queryParams["culture"] = $scope.selectedLanguage.culture; + } + var queryString = $.param(queryParams); //create the query string from the params object + } + + if (queryString) { + $scope.customTreeParams = queryString; + $scope.treeCacheKey = queryString; // this tree uses caching but we need to change it's cache key per lang + } + else { + $scope.treeCacheKey = "_"; // this tree uses caching, there's no lang selected so use the default + } + + } + + /** + * Called when the app is ready and sets up the navigation (should only be called once) + */ + function ensureInit() { + + //only run once ever! + if (isInit) { + return; + } + + isInit = true; + + var navInit = false; + + //$routeParams will be populated after $routeChangeSuccess since this controller is used outside ng-view, + //* we listen for the first route change with a section to setup the navigation. + //* we listen for all route changes to track the current section. + $rootScope.$on('$routeChangeSuccess', function () { + + //only continue if there's a section available + if ($routeParams.section) { + + if (!navInit) { + navInit = true; + initNav(); + } + + //keep track of the current section when it changes + if ($scope.currentSection != $routeParams.section) { + appState.setSectionState("currentSection", $routeParams.section); + } + + } + }); + } + + /** + * This loads the language data, if the are no variant content types configured this will return no languages + */ + function loadLanguages() { + + return contentResource.allowsCultureVariation().then(function (b) { + if (b === true) { + return languageResource.getAll() + } else { + return $q.when([]); //resolve an empty collection + } + }); + } + + /** + * Called once during init to initialize the navigation/tree/languages + */ + function initNav() { + // load languages + loadLanguages().then(function (languages) { + + $scope.languages = languages; + + if ($scope.languages.length > 1) { + //if there's already one set, check if it exists + var currCulture = null; + var mainCulture = $location.search().mculture; + if (mainCulture) { + currCulture = _.find($scope.languages, function (l) { + return l.culture.toLowerCase() === mainCulture.toLowerCase(); + }); + } + if (!currCulture) { + // no culture in the request, let's look for one in the cookie that's set when changing language + var defaultCulture = $cookies.get("UMB_MCULTURE"); + if (!defaultCulture || !_.find($scope.languages, function (l) { + return l.culture.toLowerCase() === defaultCulture.toLowerCase(); + })) { + // no luck either, look for the default language + var defaultLang = _.find($scope.languages, function (l) { + return l.isDefault; + }); + if (defaultLang) { + defaultCulture = defaultLang.culture; + } + } + $location.search("mculture", defaultCulture ? defaultCulture : null); + } + } + + $scope.currentSection = $routeParams.section; + + configureTreeAndLanguages(); + + //resolve the tree promise, set it's property values for loading the tree which will make the tree load + treeInitPromise.resolve({ + section: $scope.currentSection, + customTreeParams: $scope.customTreeParams, + cacheKey: $scope.treeCacheKey, + + //because angular doesn't return a promise for the resolve method, we need to resort to some hackery, else + //like normal JS promises we could do resolve(...).then() + onLoaded: function () { + + //the nav is ready, let the app know + eventsService.emit("app.navigationReady", { treeApi: $scope.treeApi }); + + } + }); + }); + } + function nodeExpandedHandler(args) { + //store the reference to the expanded node path + if (args.node) { + treeService._trackExpandedPaths(args.node, expandedPaths); + } + } + + $scope.selectLanguage = function (language) { + + $location.search("mculture", language.culture); + // add the selected culture to a cookie so the user will log back into the same culture later on (cookie lifetime = one year) + var expireDate = new Date(); + expireDate.setDate(expireDate.getDate() + 365); + $cookies.put("UMB_MCULTURE", language.culture, {path: "/", expires: expireDate}); + + // close the language selector + $scope.page.languageSelectorIsOpen = false; + + configureTreeAndLanguages(); //re-bind language to the query string and update the tree params + + //reload the tree with it's updated querystring args + $scope.treeApi.load({ section: $scope.currentSection, customTreeParams: $scope.customTreeParams, cacheKey: $scope.treeCacheKey }).then(function () { + + //re-sync to currently edited node + var currNode = appState.getTreeState("selectedNode"); + //create the list of promises + var promises = []; + //starting with syncing to the currently selected node if there is one + if (currNode) { + var path = treeService.getPath(currNode); + promises.push($scope.treeApi.syncTree({ path: path, activate: true })); + } + // TODO: If we want to keep all paths expanded ... but we need more testing since we need to deal with unexpanding + //for (var i = 0; i < expandedPaths.length; i++) { + // promises.push($scope.treeApi.syncTree({ path: expandedPaths[i], activate: false, forceReload: true })); + //} + //execute them sequentially + + // set selected language to active + angular.forEach($scope.languages, function(language){ + language.active = false; + }); + language.active = true; + + angularHelper.executeSequentialPromises(promises); + }); + + }; + + //this reacts to the options item in the tree + // TODO: migrate to nav service + // TODO: is this used? + $scope.searchShowMenu = function (ev, args) { + //always skip default + args.skipDefault = true; + navigationService.showMenu(args); + }; + + // TODO: migrate to nav service + // TODO: is this used? + $scope.searchHide = function () { + navigationService.hideSearch(); + }; + + //the below assists with hiding/showing the tree + var treeActive = false; + + //Sets a service variable as soon as the user hovers the navigation with the mouse + //used by the leaveTree method to delay hiding + $scope.enterTree = function (event) { + treeActive = true; + }; + + // Hides navigation tree, with a short delay, is cancelled if the user moves the mouse over the tree again + $scope.leaveTree = function (event) { + //this is a hack to handle IE touch events which freaks out due to no mouse events so the tree instantly shuts down + if (!event) { + return; + } + if (!appState.getGlobalState("touchDevice")) { + treeActive = false; + $timeout(function () { + if (!treeActive) { + navigationService.hideTree(); + } + }, 300); + } + }; + + $scope.toggleLanguageSelector = function () { + $scope.page.languageSelectorIsOpen = !$scope.page.languageSelectorIsOpen; + }; + + //ensure to unregister from all events! + $scope.$on('$destroy', function () { + for (var e in evts) { + eventsService.unsubscribe(evts[e]); + } + }); +} + +//register it +angular.module('umbraco').controller("Umbraco.NavigationController", NavigationController);