Tipos de géneros de libros - Alta, baja y modificaciones en SharePoint online

Hola a todo@s, continuando con la serie de entradas en el blog La librería, en esta entrada se pretende enseñar cómo realizar las operaciones de alta, baja y modificaciones sobre un repositorio de tipo lista en SharePoint online desde el componente SPfx.

Para conseguir este propósito en el desarrollo de nuestro componente SPfx, es necesario realizar los siguientes procesos:

  • Implementación de la lógica de negocio.

  • Implementación de los métodos del componente MockWebApiCalls.

  • Implementación de los métodos del componente SPfxWebApiCalls.

Implementación de la lógica de negocio

Antes de empezar a implementar los mecanismos que se necesitan para poder realizar las operaciones de alta, baja y modificaciones de tipos de géneros de libros, es necesario terminar de implementar la lógica de negocio del componente SPfx.

Eventos _onClickSaveGenre

Hasta ahora el evento _onClickSaveGenre del componente FormTypesOfBookGenres, solo se encargaba de cerrar el panel en el interfaz de usuario, pero ahora se va a establecer la lógica de negocio del alta y modificación de un tipo de genero de libro.

Antes de realizar la operativa de alta o de modificación, hay que realizar las clásicas operaciones de validación de formulario, como son:

  • Validar que los campos requeridos estén rellenos.

  • Validar de que no se permita duplicidad de tipos de géneros de libro.

private _onClickSaveGenre() {
    let auxErrorMessage = this.state.errorMessage;
    auxErrorMessage[Constants.ControlId.FieldTitle] = "";
    if (this.state.formUpdating[Constants.ControlId.FieldTitle].trim() !== "") {
        let continueProcess = true;
        if (!this.state.stateManagerTypesOfBookGenres.panelNew) {
            // updating element
            if (this.state.stateManagerTypesOfBookGenres.selectItem[Constants.ControlId.FieldTitle].trim().toLocaleLowerCase() === this.state.formUpdating[Constants.ControlId.FieldTitle].trim().toLocaleLowerCase()) {
                // close component
                continueProcess = false;
                this._onClickClosePanel();
            }
        }
        if (continueProcess) {
            this.state.stateManagerTypesOfBookGenres.classWebApiCalls.existGenreInRepository(this.state.formUpdating[Constants.ControlId.FieldTitle]).then((_responseExist: boolean) => {
                if (!_responseExist) {
                    if (this.state.stateManagerTypesOfBookGenres.panelNew) {
                        // add new element
                        let newItem: IItemGenre = {
                            Id: 0,
                            Title: this.state.formUpdating[Constants.ControlId.FieldTitle]
                        };
                        this.props._addGenreInRepository(newItem);
                    }
                    else {
                        // updating the element
                        let updateItem = this.state.stateManagerTypesOfBookGenres.selectItem;
                        updateItem[Constants.ControlId.FieldTitle] = this.state.formUpdating[Constants.ControlId.FieldTitle];
                        this.props._updateGenreInRepository(updateItem);
                    }
                }
                else {
                    // the element is repeated
                    auxErrorMessage[Constants.ControlId.FieldTitle] = strings.Components.ErrorMessages.RepeatedGenre;
                    this.setState({ errorMessage: auxErrorMessage });
                }
            });
        }
    }
    else {
        auxErrorMessage[Constants.ControlId.FieldTitle] = strings.Components.ErrorMessages.MustEnter;
        this.setState({ errorMessage: auxErrorMessage });
    }
}

De la lógica de negocio implementada en el proceso de alta y modificación de un tipo de género de libro, hay que destacar los siguientes conceptos:

  • Antes de realizar la operativa de crear o modificar un tipo de genero de libro, se realiza una consulta al repositorio para verificar que no haya elementos con la misma información, para evitar duplicidades. Esto se realiza a través de la función pública existGenreInRepository del objeto classWebApiCalls que implementa el interfaz IWebApiCalls.

  • Las funciones públicas addRepositoryGenres y updateRepositoryGenres del objeto classWebApiCalls que implementa el interfaz IWebApiCalls, no son requeridas en este evento. En su lugar, se invocan las funciones _addGenreInRepository y _updateGenreInRepository, que han sido proporcionadas desde el componente React ManagerTypesOfBookGenres.

  • Hay que recordar que todas las funciones públicas del interfaz IWebApiCalls, son asíncronas y que los resultados proporcionados por estas funciones han de ser procesarlas dentro de la función then del componente Promise.

Eventos _onClickDeleteGenre

El evento _onClickDeleteGenre destinado a la eliminación de un tipo de género de libro, no requiere ningún validación o control previo, ya que se implemento en entradas anteriores un Pop-Up en el que se solicitaba al usuario la confirmación del deseo de eliminar un tipo de género de libro.

private _onClickDeleteGenre() {
    this.props._deleteGenreInRepository(this.state.stateManagerTypesOfBookGenres.selectItem.Id);
}

De la lógica de negocio implementada en el proceso de baja de un tipo de género de libro, hay que destacar los siguientes conceptos:

  • La función pública deleteRepositoryGenres del objeto classWebApiCalls que implementa el interfaz IWebApiCalls, no es requerida en este evento. En su lugar, se invocan la función _deleteGenreInRepository, que han sido proporcionada desde el componente React ManagerTypesOfBookGenres.

Evento _addGenreInRepository

El evento _addGenreInRepository del componente React ManagerTypesOfBookGenres es el encargado de invocar la función addRepositoryGenres del objeto classWebApiCalls que implementa el interfaz IWebApiCalls para realizar el alta de un tipo de género de libro.

private _addGenreInRepository(_newItem: IItemGenre) {
    let auxClassWebApiCalls = this.state.classWebApiCalls;
    auxClassWebApiCalls.addRepositoryGenres(_newItem).then((_response: IListItemsGenre) => {
        this.updateState(_response, auxClassWebApiCalls);
    });
}

De la lógica de negocio implementada en el proceso de alta de un tipo de género de libro, hay que destacar los siguientes conceptos:

  • Tras la ejecución de la función addRepositoryGenres, se ha delegado los resultados obtenidos en la operativa a la función updateState encargada del comportamiento del interfaz de usuario.

Evento _updateGenreInRepository

El evento _updateGenreInRepository del componente React ManagerTypesOfBookGenres es el encargado de invocar la función updateRepositoryGenres del objeto classWebApiCalls que implementa el interfaz IWebApiCalls para realizar la actualización de un tipo de género de libro.

private _updateGenreInRepository(_updateItem: IItemGenre) {
    let auxClassWebApiCalls = this.state.classWebApiCalls;
    auxClassWebApiCalls.updateRepositoryGenres(_updateItem).then((_response: IListItemsGenre) => {
        this.updateState(_response, auxClassWebApiCalls);
    });
}

De la lógica de negocio implementada en el proceso de actualización de un tipo de género de libro, hay que destacar los siguientes conceptos:

  • Tras la ejecución de la función updateRepositoryGenres, se ha delegado los resultados obtenidos en la operativa a la función updateState encargada del comportamiento del interfaz de usuario.

Evento _deleteGenreInRepository

El evento _deleteGenreInRepository del componente React ManagerTypesOfBookGenres es el encargado de invocar la función _deleteGenreInRepository del objeto classWebApiCalls que implementa el interfaz IWebApiCalls para realizar la eliminación de un tipo de género de libro.

private _deleteGenreInRepository(id: number) {
    let auxClassWebApiCalls = this.state.classWebApiCalls;
    auxClassWebApiCalls.getRepositoryGenreById(id).then((_responseItem: IItemGenre) => {
        if (_responseItem !== null) {
            auxClassWebApiCalls.deleteRepositoryGenres(id).then((_response: IListItemsGenre) => {
                this.updateState(_response, auxClassWebApiCalls);
            });
        }
        else {
            auxClassWebApiCalls.getRepositoryGenre().then((_response: IListItemsGenre) => {
                this.updateState(_response, auxClassWebApiCalls);
            });
        }
    });
}

De la lógica de negocio implementada en el proceso de eliminación de un tipo de género de libro, hay que destacar los siguientes conceptos:

  • Tras la ejecución de la función _deleteGenreInRepository, se ha delegado los resultados obtenidos en la operativa a la función updateState encargada del comportamiento del interfaz de usuario.

  • Se invoca al método getRepositoryGenreById antes del proceso de eliminación, ya que, en el proceso de eliminación es necesario un parámetro que es proporcionado por la ejecución de esta función.

    Este comportamiento se explicará con más detalle en la implementación del proceso de eliminación de un elemento en el componente SPfxWebApiCalls.

Función updateState

Tanto los eventos de alta, baja y eliminación de un tipo de genero de libro del componente React ManagerTypesOfBookGenres usan el método updateState por que comparte la misma lógica de negocio tras realizar operaciones en el repositorio.

El cometido de esta función es las de restablecer el componente state a sus valores iniciales en cuanto al interfaz de usuario, pero con los datos a mostrar actualizados.

private updateState(_response: IListItemsGenre, _classWebApiCalls: IWebApiCalls) {
    let auxOptimizedProcesses = this.state.optimizedProcesses;
    auxOptimizedProcesses.renderedItems = _response.value;
    this.setState({
        classWebApiCalls: _classWebApiCalls,
        showPanel: false,
        panelNew: true,
        selectItem: null,
        optimizedProcesses: auxOptimizedProcesses
    });
}

De la lógica de negocio implementada en esta función, hay que destacar los siguientes conceptos:

  • No sería necesario actualizar el objeto classWebApiCalls del componente state, pero al implementar un Mock, es necesario para mantener los datos iniciales del componente SPfx cuando se desarrolla en local.

Constructor del componente React ManagerTypesOfBookGenres

Con los nuevos eventos destinados al alta, baja y modificaciones de los tipos de géneros de libros, se han definido sus declaraciones en el constructor del componente React ManagerTypesOfBookGenres, a continuación de la definición del componente state.

constructor(props: any) {
    super(props);
    this.state = {
        ...
        ...
    };
    this._onItemInvoked = this._onItemInvoked.bind(this);
    this._onClickNewGenre = this._onClickNewGenre.bind(this);
    this._closePanel = this._closePanel.bind(this);
    this._addGenreInRepository = this._addGenreInRepository.bind(this);
    this._updateGenreInRepository = this._updateGenreInRepository.bind(this);
    this._deleteGenreInRepository = this._deleteGenreInRepository.bind(this);
}

Función render

Para que el componente React FormTypesOfBookGenres, pueda usar los eventos destinados a alta, baja y modificaciones de los tipos de géneros de libros, es necesario modificar la declaración del componente FormTypesOfBookGenres en la función render del componente React ManagerTypesOfBookGenres.

public render(): React.ReactElement<IManagerTypesOfBookGenresProps> {
    console.warn("render - ManagerTypesOfBookGenres");    
    return (
      <div className={styles.managerTypesOfBookGenres}>
        ...
        ...
        <FormTypesOfBookGenres stateManagerTypesOfBookGenres={this.state}
          _closePanel={this._closePanel}
          _addGenreInRepository={this._addGenreInRepository}
          _updateGenreInRepository={this._updateGenreInRepository}
          _deleteGenreInRepository={this._deleteGenreInRepository} />
      </div>
    );
  }
}

Ficheros de recursos

La implementación de los mecanismos de control, para evitar que se produzcan duplicidades en los tipos de géneros de libros del repositorio, ha requerido de ampliar los literales en los mensajes de error.

Por ello a continuación se definen las modificaciones realizadas al respecto:

  • En el fichero mystrings.d.ts, en la definición del componente IManagerTypesOfBookGenresWebPartStrings se ha insertado la nueva definición del literal para los elementos duplicados.

    declare interface IManagerTypesOfBookGenresWebPartStrings {
      PropertyPaneDescription: string;
      Components: {
        …
        …
        ErrorMessages: {
          MustEnter: string;
          RepeatedGenre: string;
        }
      }
    }
  • En los ficheros en-us.js y es-es.js se han definido y traducido los nuevos literales.

    define([], function () {
      return {
        "PropertyPaneDescription": "En este panel emergente del web part SPfx ‘Gestor de tipos de géneros de libros’, se configuran sus características para su correcto funcionamiento.",
        "Components": {
          …
          …
          "ErrorMessages": {
            "MustEnter": "Debes introducir un valor",
            "RepeatedGenre": "Género repetido"
          }
        }
      }
    });
    
    define([], function () {
      return {
        "PropertyPaneDescription": "In this pop-up panel of the web part SPfx 'Manager of types of genres of books', its characteristics are configured for its correct functioning.",
        "Components": {
          …
          …
          "ErrorMessages": {
            "MustEnter": "You must enter a value",
            "RepeatedGenre": "Repeated genre"
          }
        }
      }
    });
    

Implementación de los métodos del componente MockWebApiCalls

Hay que mencionar que las implementaciones las funciones del componente MockWebApiCalls no son muy relevante, ya que son los procesos para insertar, eliminar o modificar elementos dentro de un array de objetos en JavaScript.

import { WebApiCalls } from './WebApiCalls';
import { IItemGenre } from './../Entities/IItemGenre';
import { IListItemsGenre } from './../Entities/IListItemsGenre';

/**
 * 
 */
export class MockWebApiCalls extends WebApiCalls {

    private listTypesOfBookGenres: IItemGenre[] = [];

    protected executeGetRepositoryGenres(): Promise<IListItemsGenre> {
        const ListGenres = ["Terror", "Romantics", "Thriller", "Fantastic", "Youth", "Historical", "Adventure", "Futurists"];
        let randomItem = this.getRandomInt(3, ListGenres.length);
        let list = null;
        let i = 0;
        list = [];
        while (i < randomItem) {
            let valueGenre = ListGenres[this.getRandomInt(0, ListGenres.length - 1)];
            if (list.findIndex((g: IItemGenre) => g.Title === valueGenre) === -1) {
                i++;
                let newItem: IItemGenre = { Id: i, Title: valueGenre };
                list.push(newItem);
            }
        }
        this.listTypesOfBookGenres = list;
        return Promise.resolve(this.sortElements());
    }

    protected executeGetRepositoryGenreById(id: number): Promise<IItemGenre> {
        let result = null;        
        for (let element of this.listTypesOfBookGenres) {
            if (element.Id === id) {
                result = element;
                break;
            }
        }
        return Promise.resolve(result);
    }

    protected executeAddRepositoryGenres(newItem: IItemGenre): Promise<IListItemsGenre> {
        this.listTypesOfBookGenres.push({ Id: this.generateId(0), Title: newItem.Title });
        return Promise.resolve(this.sortElements());
    }

    protected executeExistGenreInRepository(title: string): Promise<boolean> {
        let result = false;
        for (let element of this.listTypesOfBookGenres) {
            if (element.Title.trim().toLocaleLowerCase() === title.trim().toLocaleLowerCase()) {
                result = true;
                break;
            }
        }
        return Promise.resolve(result);
    }

    protected executeUpdateRepositoryGenres(updateItem: IItemGenre): Promise<IListItemsGenre> {
        let index = -1;
        for (let x = 0; x < this.listTypesOfBookGenres.length; x++) {
            if (this.listTypesOfBookGenres[x].Id === updateItem.Id) {
                index = x;
                break;
            }
        }
        if (index >= 0) {
            this.listTypesOfBookGenres.splice(index, 1);
            this.listTypesOfBookGenres.push(updateItem);
        }
        return Promise.resolve(this.sortElements());
    }

    protected executeDeleteRepositoryGenres(id: number): Promise<IListItemsGenre> {
        let index = -1;
        for (let x = 0; x < this.listTypesOfBookGenres.length; x++) {
            if (this.listTypesOfBookGenres[x].Id === id) {
                index = x;
                break;
            }
        }
        if (index >= 0) {
            this.listTypesOfBookGenres.splice(index, 1);
        }
        return Promise.resolve(this.sortElements());
    }

    private getRandomInt(min: number, max: number) {
        return Math.floor(Math.random() * (max - min)) + min;
    }

    private sortElements(): IListItemsGenre {
        let result: IListItemsGenre = { value: this.listTypesOfBookGenres.sort((a, b) => a.Title.localeCompare(b.Title)) };
        return result;
    }

    private generateId(addDay: number) {
        let now = new Date();
        if (addDay !== 0) {
            now = new Date(now.getFullYear(), now.getMonth(), now.getDate() + addDay, now.getHours(), now.getMinutes(), now.getSeconds());
        }
        var dd = now.getDate();
        var MM = now.getMonth();
        var hh = now.getHours();
        var mm = now.getMinutes();
        var ss = now.getSeconds();
        return parseInt(now.getFullYear().toString() + MM + dd + hh + mm + ss, 10);
    }
}

Implementación de los métodos del componente SPfxWebApiCalls

A continuación, se van a explicar las implementaciones del componente SPfxWebApiCalls para poder realizar el alta, baja y modificaciones de los tipos de géneros de libros desde un repositorio de SharePoint online.

Función executeExistGenreInRepository

Esta función implementa el mecanismo GET de búsqueda de tipos de géneros de libros, filtrando la consulta REST a través de título aportado en SharePoint online.

protected async executeExistGenreInRepository(title: string): Promise<boolean> {
    let requestUrl = this._pageContext.site.absoluteUrl.concat("/_api/web/Lists/GetByTitle('", Constants.Lists.ByName.Genres, "')/items?$select=Id,Title&$filter=Title eq '", title, "'");
    const response = await this._spHttpClient.get(requestUrl, SPHttpClient.configurations.v1);
    return response.json().then((responseJson: IListItemsGenre) => {
        return Promise.resolve(responseJson.value.length > 0 ? true : false);
    });
}

Función executeAddRepositoryGenres

En esta función se implementa el mecanismo POST de agregación de un nuevo tipo de género de libro al repositorio de SharePoint online.

protected async executeAddRepositoryGenres(newItem: IItemGenre): Promise<IListItemsGenre> {
    const bodyMetaData: string = JSON.stringify({
        'Title': newItem.Title
    });
    let requestUrl = this._pageContext.site.absoluteUrl.concat("/_api/web/Lists/GetByTitle('", Constants.Lists.ByName.Genres, "')/items");
    await this._spHttpClient.post(requestUrl, SPHttpClient.configurations.v1,
        {
            headers: {
                'Accept': 'application/json;odata=nometadata',
                'Content-type': 'application/json;odata=nometadata',
                'odata-version': ''
            },
            body: bodyMetaData
        });
    return this.executeGetRepositoryGenres();
}     

De la lógica de negocio implementada en esta función para crear elementos en una lista de SharePoint online, hay que destacar los siguientes conceptos:

  • Es necesario disponer de un Json con la configuración de las columnas que se quieren rellenar en el momento de crear el nuevo elemento.

  • Es necesario definir los protocolos de la cabecera POST, para poder realizar la operativa de creación de un nuevo elemento.

  • Una vez realizada la operativa de agregar un nuevo elemento en la lista, se obtiene de todos los elementos de la lista, para actualizar el interfaz de usuario.

Función executeUpdateRepositoryGenres

En esta función se implementa el mecanismo POST, no el mecanismo PUT para la actualización de un tipo de género de libro en el repositorio de SharePoint online.

protected async executeUpdateRepositoryGenres(updateItem: IItemGenre): Promise<IListItemsGenre> {
    const bodyMetaData: string = JSON.stringify({
        'Title': updateItem.Title
    });
    let requestUrl = this._pageContext.site.absoluteUrl.concat("/_api/web/Lists/GetByTitle('", Constants.Lists.ByName.Genres, "')/items(", updateItem.Id.toString(), ")");
    await this._spHttpClient.post(requestUrl, SPHttpClient.configurations.v1,
        {
            headers: {
                'Accept': 'application/json;odata=nometadata',
                'Content-type': 'application/json;odata=nometadata',
                'odata-version': '',
                'IF-MATCH': '*',
                'X-HTTP-Method': 'MERGE'
            },
            body: bodyMetaData
        });
    return this.executeGetRepositoryGenres();
}         

De la lógica de negocio implementada en esta función para actualizar el elemento de una lista de SharePoint online, hay que destacar los siguientes conceptos:

  • Es necesario disponer de un Json con la configuración de las columnas que se quieren actualizar en el momento de modificar el elemento de la lista.

  • Es necesario definir los protocolos de la cabecera POST, para poder realizar la operativa de actualización de un elemento de la lista.

  • Una vez realizada la operativa de agregar un nuevo elemento en la lista, se obtiene de todos los elementos de la lista, para actualizar el interfaz de usuario.

Función executeDeleteRepositoryGenres

En esta función se implementa el mecanismo POST, no el mecanismo DELETE para la eliminación de un tipo de género de libro en el repositorio de SharePoint online.

protected async executeDeleteRepositoryGenres(id: number): Promise<IListItemsGenre> {
    let requestUrl = this._pageContext.site.absoluteUrl.concat("/_api/web/Lists/GetByTitle('", Constants.Lists.ByName.Genres, "')/items(", id.toString(), ")");
    await this._spHttpClient.post(requestUrl, SPHttpClient.configurations.v1,
        {
            headers: {
                'Accept': 'application/json;odata=nometadata',
                'Content-type': 'application/json;odata=verbose',
                'odata-version': '',
                'IF-MATCH': this._ETag,
                'X-HTTP-Method': 'DELETE'
            }
        });
    return this.executeGetRepositoryGenres();
}           

De la lógica de negocio implementada en esta función para eliminar un elemento de la lista de SharePoint online, hay que destacar los siguientes conceptos:

  • Es necesario definir los protocolos de la cabecera POST, para poder realizar la operativa de eliminación de un elemento de la lista.

    Para establecer los protocolos correctos para la eliminación de un elemento de la lista de SharePoint online, por estos mecanismos, es necesario disponer de un valor concreto para el parámetro IF-MATCH.

    Este valor solo lo he podido obtener a través de una consulta directamente por ID de la lista del elemento, ya que de otra forma no he podido realizar la operativa.

  • Una vez realizada la operativa de agregar un nuevo elemento en la lista, se obtiene de todos los elementos de la lista, para actualizar el interfaz de usuario.

Función executeGetRepositoryGenreById

En esta función se implementa el mecanismo GET, para la obtención de un tipo de género de libro en el repositorio de SharePoint online a través de su identificador.

protected async executeGetRepositoryGenreById(id: number): Promise<IItemGenre> {
    let requestUrl = this._pageContext.site.absoluteUrl.concat("/_api/web/Lists/GetByTitle('", Constants.Lists.ByName.Genres, "')/items(", id.toString(), ")?$select=Id,Title");
    const response = await this._spHttpClient.get(requestUrl, SPHttpClient.configurations.v1);
    if (response.status === 200) {
        this._ETag = response.headers.get('ETag');
        return response.json();
    } else {
        return null;
    }
} 

De la lógica de negocio implementada en esta función para obtener un elemento de la lista de SharePoint online a través de su identificador, hay que destacar los siguientes conceptos:

  • Se ha creado una variable privada para almacenar un dato esencial en los procesos de eliminación de elementos en lista.

    private _ETag: string;
  • Si existe un elemento con el identificador proporcionado en la función, antes de devolver el resultado de la operativa, se extrae de la cabecera del proceso un identificador o código, necesario para los procesos de eliminación de elementos en la lista.

  • Quiero recordar que las consultas realizadas por identificador de elemento directamente sobre la lista, puede provocar una excepción si no existe ningún elemento con ese identificador.

    Ejemplo del error producido al no existir un elemento en la lista con el ID proporcionado

Ejecutar componente SPfx

Una vez implementado todas las lógicas de negocio necesarias para realizar las operativas de alta, baja y modificación de tipos de géneros de libros, se va a proceder a comprobar su funcionamiento en los diferentes entornos.

Entono local

En un entorno Local, al ser emulado los procesos y siendo el repositorio de la información un array en memoria, solo se puede comprobar que todas las implementaciones realizadas en este entorno son correctas a través del correcto funcionamiento del interfaz de usuario implementado.

He de informar que por alguna razón que desconozco, al actualizar la información, no se visualiza de forma automática los cambios en el control DetailsList de DetailsList. Pero, si se mueve el scroll del navegador, automáticamente se refresca el control sin saltar ningún evento.

He estado leyendo que puede ser un problema de la versión del componente en DetailsList, pero como vi que funcionaba correctamente el control DetailsList en los entornos de SharePoint online, no invertí más tiempo en resolver el problema.

Entorno SharePoint online

Desplegado el componente SPfx, sobre la herramienta de desarrollo, pero estando conectados a una colección de sitio del tenant se comprueba que los datos iniciales mostrados en el interfaz de usuario son los mismos que hay en la lista de la colección de sitios.

Si se realiza las operaciones de alta, baja y modificaciones de los elementos de la lista, a través del componente SPfx desarrollado, se podrá comprobar que los cambios realizados, se reflejan automáticamente en la lista de SharePoint online.

Ejemplo de como dar de alta un nuevo tipo de genero de libro y luego eliminarlo en SharePoint online

Entradas populares de este blog

Cargar archivos desde PowerApps a bibliotecas de SharePoint

Menús desplegables relacionados en SharePoint Online

Gestionar excepciones en Power Automate