diff --git a/e2e/renderer/app/action-bar/action-bar-dynamic-items.component.ts b/e2e/renderer/app/action-bar/action-bar-dynamic-items.component.ts new file mode 100644 index 000000000..0614e0798 --- /dev/null +++ b/e2e/renderer/app/action-bar/action-bar-dynamic-items.component.ts @@ -0,0 +1,27 @@ +import { Component } from "@angular/core"; + +@Component({ + template: ` + + + + + + + + + + + + + ` +}) +export class ActionBarDynamicItemsComponent { + public showNavigationButton = true; + public show1 = true; + public show2 = true; +} + diff --git a/e2e/renderer/app/action-bar/action-bar-extension.component.ts b/e2e/renderer/app/action-bar/action-bar-extension.component.ts new file mode 100644 index 000000000..7d3b9b8e8 --- /dev/null +++ b/e2e/renderer/app/action-bar/action-bar-extension.component.ts @@ -0,0 +1,16 @@ +import { Component } from "@angular/core"; + +@Component({ + template: ` + + + + + + + + ` +}) +export class ActionBarExtensionComponent { + public show = true; +} diff --git a/e2e/renderer/app/app-routing.module.ts b/e2e/renderer/app/app-routing.module.ts index 576923862..a975d5be8 100644 --- a/e2e/renderer/app/app-routing.module.ts +++ b/e2e/renderer/app/app-routing.module.ts @@ -1,6 +1,9 @@ import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core"; import { NativeScriptRouterModule } from "nativescript-angular/router"; +import { ActionBarDynamicItemsComponent } from "./action-bar/action-bar-dynamic-items.component"; +import { ActionBarExtensionComponent } from "./action-bar/action-bar-extension.component"; + import { ListComponent } from "./list.component"; import { NgForComponent } from "./ngfor.component"; import { NgForOfComponent } from "./ngforof.component"; @@ -17,6 +20,14 @@ export const routes = [ redirectTo: "/list", pathMatch: "full" }, + { + path: "action-bar-dynamic", + component: ActionBarDynamicItemsComponent, + }, + { + path: "action-bar-extension", + component: ActionBarExtensionComponent, + }, { path: "list", component: ListComponent, @@ -56,6 +67,9 @@ export const routes = [ ]; export const navigatableComponents = [ + ActionBarDynamicItemsComponent, + ActionBarExtensionComponent, + ListComponent, NgForComponent, NgForOfComponent, diff --git a/e2e/renderer/app/content-view.component.ts b/e2e/renderer/app/content-view.component.ts index 6ec92a1ba..a2aeb50ba 100644 --- a/e2e/renderer/app/content-view.component.ts +++ b/e2e/renderer/app/content-view.component.ts @@ -1,7 +1,6 @@ import { Component } from "@angular/core"; @Component({ - selector: "my-app", template: ` diff --git a/e2e/renderer/app/list.component.ts b/e2e/renderer/app/list.component.ts index ec4ca839c..ddbaa8b95 100644 --- a/e2e/renderer/app/list.component.ts +++ b/e2e/renderer/app/list.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; @Component({ template: ` + + diff --git a/e2e/renderer/e2e/action-bar.e2e-spec.ts b/e2e/renderer/e2e/action-bar.e2e-spec.ts new file mode 100644 index 000000000..20f63d24b --- /dev/null +++ b/e2e/renderer/e2e/action-bar.e2e-spec.ts @@ -0,0 +1,163 @@ +import { + AppiumDriver, + createDriver, + SearchOptions, + elementHelper, +} from "nativescript-dev-appium"; + +import { isOnTheLeft } from "./helpers/location"; +import { DriverWrapper, ExtendedUIElement } from "./helpers/appium-elements"; + +describe("Action Bar scenario", () => { + let driver: AppiumDriver; + let driverWrapper: DriverWrapper; + + describe("dynamically add/remove ActionItems", async () => { + let firstActionItem: ExtendedUIElement; + let secondActionItem: ExtendedUIElement; + let toggleFirstButton: ExtendedUIElement; + let toggleSecondButton: ExtendedUIElement; + + before(async () => { + driver = await createDriver(); + driverWrapper = new DriverWrapper(driver); + }); + + after(async () => { + await driver.quit(); + console.log("Driver quits!"); + }); + + it("should navigate to page", async () => { + const navigationButton = + await driverWrapper.findElementByText("ActionBar dynamic", SearchOptions.exact); + await navigationButton.click(); + + const actionBar = + await driverWrapper.findElementByText("Action Bar Dynamic Items", SearchOptions.exact); + }); + + it("should find elements", async () => { + firstActionItem = await driverWrapper.findElementByText("one"); + secondActionItem = await driverWrapper.findElementByText("two"); + + toggleFirstButton = await driverWrapper.findElementByText("toggle 1"); + toggleSecondButton = await driverWrapper.findElementByText("toggle 2"); + }); + + it("should initially render the action items in the correct order", async () => { + await checkOrderIsCorrect(); + }); + + it("should detach first element when its condition is false", done => { + (async () => { + await toggleFirst(); + + try { + await driverWrapper.findElementByText("one", SearchOptions.exact); + } catch (e) { + done(); + } + })(); + }); + + it("should attach first element in the correct position", async () => { + await toggleFirst(); + await checkOrderIsCorrect(); + }); + + it("should detach second element when its condition is false", done => { + (async () => { + await toggleSecond(); + + try { + await driverWrapper.findElementByText("two", SearchOptions.exact); + } catch (e) { + done(); + } + })(); + }); + + it("should attach second element in the correct position", async () => { + await toggleSecond(); + await checkOrderIsCorrect(); + }); + + it("should detach and then reattach both at correct places", async () => { + await toggleFirst(); + await toggleSecond(); + + await toggleFirst(); + await toggleSecond(); + + await checkOrderIsCorrect(); + }); + + const checkOrderIsCorrect = async () => { + await isOnTheLeft(firstActionItem, secondActionItem); + }; + + const toggleFirst = async () => { + toggleFirstButton = await toggleFirstButton.refetch(); + await toggleFirstButton.click(); + }; + + const toggleSecond = async () => { + toggleSecondButton = await toggleSecondButton.refetch(); + await toggleSecondButton.click(); + }; + + }); + + describe("Action Bar extension with dynamic ActionItem", async () => { + let toggleButton: ExtendedUIElement; + let conditional: ExtendedUIElement; + + before(async () => { + driver = await createDriver(); + driverWrapper = new DriverWrapper(driver); + }); + + after(async () => { + await driver.quit(); + console.log("Driver quits!"); + }); + + it("should navigate to page", async () => { + const navigationButton = + await driverWrapper.findElementByText("ActionBarExtension", SearchOptions.exact); + await navigationButton.click(); + }); + + it("should find elements", async () => { + toggleButton = await driverWrapper.findElementByText("toggle"); + conditional = await driverWrapper.findElementByText("conditional"); + }); + + it("should detach conditional action item when its condition is false", done => { + (async () => { + await toggle(); + + try { + await driverWrapper.findElementByText("conditional", SearchOptions.exact); + } catch (e) { + done(); + } + })(); + }); + + it("should reattach conditional action item at correct place", async () => { + await toggle(); + await checkOrderIsCorrect(); + }); + + const checkOrderIsCorrect = async () => { + await isOnTheLeft(toggleButton, conditional); + }; + + const toggle = async () => { + toggleButton = await toggleButton.refetch(); + await toggleButton.click(); + }; + }); +}); diff --git a/e2e/renderer/e2e/helpers/appium-elements.ts b/e2e/renderer/e2e/helpers/appium-elements.ts index 26a020a4d..203808169 100644 --- a/e2e/renderer/e2e/helpers/appium-elements.ts +++ b/e2e/renderer/e2e/helpers/appium-elements.ts @@ -38,4 +38,25 @@ export class DriverWrapper { return result; } + + @refetchable() + async findElementByXPath(...args: any[]): Promise { + const result = await (this.driver).findElementByXPath(...args); + + return result; + } + + @refetchable() + async findElementsByXPath(...args: any[]): Promise { + const result = await (this.driver).findElementsByXPath(...args); + + return result || []; + } + + @refetchable() + async findElementsByClassName(...args: any[]): Promise { + const result = await (this.driver).findElementsByClassName(...args); + + return result || []; + } } diff --git a/e2e/renderer/e2e/helpers/location.ts b/e2e/renderer/e2e/helpers/location.ts index 189dfbbcc..df2a7cc67 100644 --- a/e2e/renderer/e2e/helpers/location.ts +++ b/e2e/renderer/e2e/helpers/location.ts @@ -11,3 +11,14 @@ export const isAbove = async (first: ExtendedUIElement, second: ExtendedUIElemen assert.isTrue(firstY < secondY); } + +export const isOnTheLeft = async (first: ExtendedUIElement, second: ExtendedUIElement) => { + first = await first.refetch(); + second = await second.refetch(); + + const { x: firstX } = await first.location(); + const { x: secondX } = await second.location(); + + assert.isTrue(firstX < secondX); +} + diff --git a/nativescript-angular/directives/action-bar.ts b/nativescript-angular/directives/action-bar.ts index 5158a1532..1b9053dd2 100644 --- a/nativescript-angular/directives/action-bar.ts +++ b/nativescript-angular/directives/action-bar.ts @@ -1,54 +1,87 @@ import { Directive, Component, ElementRef, Optional, OnDestroy } from "@angular/core"; -import { ActionItem, ActionBar, NavigationButton } from "tns-core-modules/ui/action-bar"; +import { + ActionBar, + ActionItem, + ActionItems, + NavigationButton, +} from "tns-core-modules/ui/action-bar"; import { Page } from "tns-core-modules/ui/page"; -import { View } from "tns-core-modules/ui/core/view"; import { isBlank } from "../lang-facade"; import { - InvisibleNode, NgView, ViewClassMeta, + ViewExtensions, + isInvisibleNode, + isView, registerElement, } from "../element-registry"; +export function isActionItem(view: any): view is ActionItem { + return view instanceof ActionItem; +} + +export function isNavigationButton(view: any): view is NavigationButton { + return view instanceof NavigationButton; +} + +type NgActionBar = (ActionBar & ViewExtensions); + const actionBarMeta: ViewClassMeta = { skipAddToDom: true, - insertChild: (parent: NgView, child: NgView) => { - const bar = (parent); - const childView = child; - - if (child instanceof InvisibleNode) { + insertChild: (parent: NgActionBar, child: NgView, next: any) => { + if (isInvisibleNode(child)) { return; - } else if (child instanceof NavigationButton) { - bar.navigationButton = childView; - childView.parent = bar; - } else if (child instanceof ActionItem) { - bar.actionItems.addItem(childView); - childView.parent = bar; - } else if (child instanceof View) { - bar.titleView = childView; + } else if (isNavigationButton(child)) { + parent.navigationButton = child; + child.templateParent = parent; + } else if (isActionItem(child)) { + addActionItem(parent, child, next); + child.templateParent = parent; + } else if (isView(child)) { + parent.titleView = child; } }, - removeChild: (parent: NgView, child: NgView) => { - const bar = (parent); - const childView = child; - - if (child instanceof InvisibleNode) { + removeChild: (parent: NgActionBar, child: NgView) => { + if (isInvisibleNode(child)) { return; - } else if (child instanceof NavigationButton) { - if (bar.navigationButton === childView) { - bar.navigationButton = null; + } else if (isNavigationButton(child)) { + if (parent.navigationButton === child) { + parent.navigationButton = null; } - childView.parent = null; - } else if (child instanceof ActionItem) { - bar.actionItems.removeItem(childView); - childView.parent = null; - } else if (child instanceof View && bar.titleView && bar.titleView === childView) { - bar.titleView = null; + + child.templateParent = null; + } else if (isActionItem(child)) { + parent.actionItems.removeItem(child); + child.templateParent = null; + } else if (isView(child) && parent.titleView && parent.titleView === child) { + parent.titleView = null; } }, }; +const addActionItem = (bar: NgActionBar, item: ActionItem, next: ActionItem) => { + if (next) { + insertActionItemBefore(bar, item, next); + } else { + appendActionItem(bar, item); + } +}; + +const insertActionItemBefore = (bar: NgActionBar, item: ActionItem, next: ActionItem) => { + const actionItems: ActionItems = bar.actionItems; + const actionItemsCollection: ActionItem[] = actionItems.getItems(); + + const indexToInsert = actionItemsCollection.indexOf(next); + actionItemsCollection.splice(indexToInsert, 0, item); + + (actionItems).setItems(actionItemsCollection); +}; + +const appendActionItem = (bar: NgActionBar, item: ActionItem) => { + bar.actionItems.addItem(item); +}; + registerElement("ActionBar", () => require("ui/action-bar").ActionBar, actionBarMeta); registerElement("ActionItem", () => require("ui/action-bar").ActionItem); registerElement("NavigationButton", () => require("ui/action-bar").NavigationButton); diff --git a/nativescript-angular/element-registry.ts b/nativescript-angular/element-registry.ts index 8c0fad5f1..35f111cf8 100644 --- a/nativescript-angular/element-registry.ts +++ b/nativescript-angular/element-registry.ts @@ -72,14 +72,22 @@ const getClassName = instance => instance.constructor.name; export interface ViewClassMeta { skipAddToDom?: boolean; - insertChild?: (parent: NgView, child: NgView) => void; - removeChild?: (parent: NgView, child: NgView) => void; + insertChild?: (parent: any, child: any, next?: any) => void; + removeChild?: (parent: any, child: any) => void; } export function isDetachedElement(element): boolean { return (element && element.meta && element.meta.skipAddToDom); } +export function isView(view: any): view is NgView { + return view instanceof View; +} + +export function isInvisibleNode(view: any): view is InvisibleNode { + return view instanceof InvisibleNode; +} + export type ViewResolver = () => ViewClass; const elementMap = new Map(); diff --git a/nativescript-angular/view-util.ts b/nativescript-angular/view-util.ts index 5532a39dc..f1075bac0 100644 --- a/nativescript-angular/view-util.ts +++ b/nativescript-angular/view-util.ts @@ -12,7 +12,9 @@ import { getViewClass, getViewMeta, isDetachedElement, + isInvisibleNode, isKnownView, + isView, } from "./element-registry"; import { platformNames, Device } from "tns-core-modules/platform"; @@ -29,10 +31,6 @@ export type NgContentView = ContentView & ViewExtensions; export type NgPlaceholder = Placeholder & ViewExtensions; export type BeforeAttachAction = (view: View) => void; -export function isView(view: any): view is NgView { - return view instanceof View; -} - export function isLayout(view: any): view is NgLayoutBase { return view instanceof LayoutBase; } @@ -53,23 +51,30 @@ export class ViewUtil { } public insertChild( - parent: NgView, - child: NgView, - previous: NgView = parent.lastChild, + parent: View, + child: View, + previous?: NgView, next?: NgView ) { if (!parent) { return; } - this.addToQueue(parent, child, previous, next); + const extendedParent = this.ensureNgViewExtensions(parent); + const extendedChild = this.ensureNgViewExtensions(child); - if (child instanceof InvisibleNode) { - child.templateParent = parent; + if (!previous) { + previous = extendedParent.lastChild; + } + this.addToQueue(extendedParent, extendedChild, previous, next); + + if (isInvisibleNode(child)) { + extendedChild.templateParent = extendedParent; } if (!isDetachedElement(child)) { - this.addToVisualTree(parent, child, next); + const nextVisual = this.findNextVisual(next); + this.addToVisualTree(extendedParent, extendedChild, nextVisual); } } @@ -79,6 +84,9 @@ export class ViewUtil { previous: NgView, next: NgView ): void { + traceLog(`ViewUtil.addToQueue parent: ${parent}, view: ${child}, ` + + `previous: ${previous}, next: ${next}`); + if (previous) { previous.nextSibling = child; } else { @@ -94,6 +102,7 @@ export class ViewUtil { private appendToQueue(parent: NgView, view: NgView) { traceLog(`ViewUtil.appendToQueue parent: ${parent} view: ${view}`); + if (parent.lastChild) { parent.lastChild.nextSibling = view; } @@ -102,8 +111,10 @@ export class ViewUtil { } private addToVisualTree(parent: NgView, child: NgView, next: NgView): void { + traceLog(`ViewUtil.addToVisualTree parent: ${parent}, view: ${child}, next: ${next}`); + if (parent.meta && parent.meta.insertChild) { - parent.meta.insertChild(parent, child); + parent.meta.insertChild(parent, child, next); } else if (isLayout(parent)) { this.insertToLayout(parent, child, next); } else if (isContentView(parent)) { @@ -131,7 +142,7 @@ export class ViewUtil { } } - private findNextVisual(view: NgView) { + private findNextVisual(view: NgView): NgView { let next = view; while (next && isDetachedElement(next)) { next = next.nextSibling; @@ -140,37 +151,22 @@ export class ViewUtil { return next; } - public removeChild(parent: NgView, child: NgView) { + public removeChild(parent: View, child: View) { + traceLog(`ViewUtil.removeChild parent: ${parent} child: ${child}`); + if (!parent) { return; } - if (parent.meta && parent.meta.removeChild) { - parent.meta.removeChild(parent, child); - } else if (isLayout(parent)) { - this.removeLayoutChild(parent, child); - } else if (isContentView(parent) && parent.content === child) { - parent.content = null; - parent.lastChild = null; - parent.firstChild = null; - } else if (isView(parent)) { - parent._removeView(child); - } - } + const extendedParent = this.ensureNgViewExtensions(parent); + const extendedChild = this.ensureNgViewExtensions(child); - private removeLayoutChild(parent: NgLayoutBase, child: NgView): void { - const index = parent.getChildIndex(child); - this.removeFromQueue(parent, child, index); - if (index === -1) { - return; - } - - parent.removeChild(child); + this.removeFromQueue(extendedParent, extendedChild); + this.removeFromVisualTree(extendedParent, extendedChild); } - private removeFromQueue(parent: NgLayoutBase, child: NgView, index: number) { - traceLog(`ViewUtil.removeFromQueue ` + - `parent: ${parent} child: ${child} index: ${index}`); + private removeFromQueue(parent: NgView, child: NgView) { + traceLog(`ViewUtil.removeFromQueue parent: ${parent} child: ${child}`); if (parent.firstChild === child && parent.lastChild === child) { parent.firstChild = null; @@ -182,7 +178,7 @@ export class ViewUtil { parent.firstChild = child.nextSibling; } - const previous = this.findPreviousElement(parent, child, index); + const previous = this.findPreviousElement(parent, child); if (parent.lastChild === child) { parent.lastChild = previous; } @@ -193,8 +189,14 @@ export class ViewUtil { } // NOTE: This one is O(n) - use carefully - private findPreviousElement(parent: NgLayoutBase, child: NgView, elementIndex: number): NgView { - const previousVisual = this.getPreviousVisualElement(parent, elementIndex); + private findPreviousElement(parent: NgView, child: NgView): NgView { + traceLog(`ViewUtil.findPreviousElement parent: ${parent} child: ${child}`); + + let previousVisual; + if (isLayout(parent)) { + previousVisual = this.getPreviousVisualElement(parent, child); + } + let previous = previousVisual || parent.firstChild; // since detached elements are not added to the visual tree, @@ -207,7 +209,9 @@ export class ViewUtil { return previous; } - private getPreviousVisualElement(parent: NgLayoutBase, elementIndex: number): NgView { + private getPreviousVisualElement(parent: NgLayoutBase, child: NgView): NgView { + const elementIndex = parent.getChildIndex(child); + if (elementIndex > 0) { return parent.getChildAt(elementIndex - 1) as NgView; } @@ -222,6 +226,30 @@ export class ViewUtil { } } + private removeFromVisualTree(parent: NgView, child: NgView) { + traceLog(`ViewUtil.findPreviousElement parent: ${parent} child: ${child}`); + + if (parent.meta && parent.meta.removeChild) { + parent.meta.removeChild(parent, child); + } else if (isLayout(parent)) { + this.removeLayoutChild(parent, child); + } else if (isContentView(parent) && parent.content === child) { + parent.content = null; + parent.lastChild = null; + parent.firstChild = null; + } else if (isView(parent)) { + parent._removeView(child); + } + } + + private removeLayoutChild(parent: NgLayoutBase, child: NgView): void { + const index = parent.getChildIndex(child); + + if (index !== -1) { + parent.removeChild(child); + } + } + public createComment(): InvisibleNode { return new CommentNode(); } @@ -238,16 +266,34 @@ export class ViewUtil { } const viewClass = getViewClass(name); - let view = new viewClass(); - view.nodeName = name; - view.meta = getViewMeta(name); + const view = new viewClass(); + const ngView = this.setNgViewExtensions(view, name); + + return ngView; + } + + private ensureNgViewExtensions(view: View): NgView { + if (view.hasOwnProperty("meta")) { + return view as NgView; + } else { + const name = view.typeName; + const ngView = this.setNgViewExtensions(view, name); + + return ngView; + } + } + + private setNgViewExtensions(view: View, name: string): NgView { + const ngView = view as NgView; + ngView.nodeName = name; + ngView.meta = getViewMeta(name); // we're setting the node type of the view // to 'element' because of checks done in the // dom animation engine - view.nodeType = ELEMENT_NODE_TYPE; + ngView.nodeType = ELEMENT_NODE_TYPE; - return view; + return ngView; } public setProperty(view: NgView, attributeName: string, value: any, namespace?: string): void { @@ -363,4 +409,3 @@ export class ViewUtil { view.style[styleName] = unsetValue; } } -