anton-kravchenko / cypress-selectors

cypress-selectors is a library that provides a bunch of convenient declarative selectors for Cypress.
MIT License
13 stars 1 forks source link

How can I combine static decorated properties with a parent selector? #32

Closed GrayedFox closed 1 year ago

GrayedFox commented 1 year ago

Right now I have a class that uses selector methods as property decorators, which is fine for static values, but I would like to pass in a Selector at runtime so that I can create or return a class instance with different parents.

I would like to be able to do something like this and pass in a string as a value to the class constructor at runtime:

const urlAttribute: ExternalSelectorConfig = { attribute: 'product-url' };

class Parent {
  readonly selector: Selector;

  constructor(url: string) {
    this.selector = By.Attribute(url, testAttribute);
  }
}

So that I can then do something like this:

const parent = new Parent('www.my-url.com');
const testAttribute: ExternalSelectorConfig = { attribute: 'data-testid', parent: parent.selector };

class ProductCardSelectors {
  @By.Attribute('product-title', testAttribute) static title: Selector;
  @By.Attribute('product-images-slide', testAttribute) static image: Selector;
  @By.Attribute('product-card-add', testAttribute) static addButton: Selector;
  @By.Attribute('product-price', testAttribute) static price: Selector;
  @By.Attribute('product-merchant', testAttribute) static vendor: Selector;
  @By.Attribute('stock', partAttribute) static outOfStockBadge: Selector;
  @By.Attribute('card-container disabled', partAttribute)
  static disabledCard: Selector;
}

Typescript throws an error due to me using the decorator function as a standard function:

Type '(host: Host, property: string) => any' is not assignable to type 'Selector'.ts(2322)

I can get around the error by casting to unknown and then to Selector, but will that work as expected? Is there an easier way to achieve what I am trying to do here? From what I understand decorators do nothing until runtime - but maybe I should be using the method as a method decorator or class decorator instead?

Pretty new to this functionality in TypeScript, been reading this but am still learning: https://www.digitalocean.com/community/tutorials/how-to-use-decorators-in-typescript#creating-method-decorators

GrayedFox commented 1 year ago

Posting this custom command here as a workaround - if you want to have static selector classes but still need to base the selection based on a dynamic parent element, this custom command does the trick:

// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace Cypress {
  interface Chainable {
    /**
     * Custom command that wraps a child element found within a parent
     * @param
     * @example cy.getChild(parent, child);
     */
    getChild: typeof getChild;
  }
}

function getChild(
  parent: Cypress.Chainable,
  child: Cypress.Chainable
): Cypress.Chainable {
  let childWithin: Cypress.Chainable;
  return parent
    .within(() => {
      child.then(($el) => {
        childWithin = $el;
      });
    })
    .then(() => {
      return cy.wrap(childWithin);
    });
}

Cypress.Commands.add('getChild', getChild);

Pass in either a parent Selector or query (i.e. cy.getChild(cy.get('parent-selector', DecoratedSelector)) and the custom command will yield a chainable interface so you can use Mocha and Chai assertions straight off the top of it.

anton-kravchenko commented 1 year ago

@GrayedFox I guess the only way to accomplish 'dynamic' parents with static selectors is by so-called using mixins. Basically, you would need a function that takes a parent selector and returns a new class where static selectors are initialized with the proper parent selector. This solution is clunky though and probably your's solution with getChild is cleaner.

jonathanantoine commented 1 year ago

Here is a little helper of my own which let you contextualize a selector holders withing a parent :

export const ForParent = <T>(parent: Selector, type: { new(): T; }): T => {

  const instance = new type();

  let childHolder: any = {};
  const contextualizedObject: any = {...instance};
  Object.getOwnPropertyNames(Object.getPrototypeOf(instance))
    .filter(key => key !== 'constructor')
    .forEach(key => {

      // @ts-ignore
      const selector = instance[key] as Selector;
      if (!selector) {
        return;
      }

      Object.defineProperty(contextualizedObject, key, {
        // Create a new getter for the property
        get: function () {
          return  parent
            .within(() => selector.then(($el: any) => childHolder[key] = $el))
            .then(() => cy.wrap(childHolder[key]))

        }
      })
    })

  return contextualizedObject ;
}

Usage :

const mySelectors= ForParent(parentSelector , SelectorHolders);

mySelectors.MyTitle.should('have.text', 'lol);

With this definitions :

export class SelectorHolders{

  @By.Attribute('my-title')
  MyTitle: Selector;

  @By.Attribute('my-subtitle')
  MySubTitle: Selector;

}
anton-kravchenko commented 1 year ago

@jonathanantoine that's awesome! Thank you for sharing!