From d29e1f4855affc3bc67904926f4e1b023d48b5b2 Mon Sep 17 00:00:00 2001 From: Nitzan Peretz Date: Tue, 3 Dec 2024 17:32:49 +0400 Subject: [PATCH] feat: Support custom fields in user-profile-widget + Edit/Delete flows (#843) ## Related Issues fixes https://github.com/descope/etc/issues/8220 Part of https://github.com/descope/etc/issues/6501 ## Description Widget Profile widget to support custom fields presentation - created a new mixin to support custom fields userAttribute component. ![image](https://github.com/user-attachments/assets/6a6fed3b-6587-4160-aaaa-d466dabaa7c7) --------- Signed-off-by: Nitzan Peretz Co-authored-by: nirgur --- .../user-profile-widget/jest.config.js | 4 +- .../src/lib/widget/api/sdk/index.ts | 2 +- .../src/lib/widget/api/types.ts | 9 + .../initUserCustomAttributesMixin.ts | 171 ++++++++++++++++++ .../lib/widget/mixins/initMixin/initMixin.ts | 8 +- .../src/lib/widget/state/selectors.ts | 5 + ...et.test.ts => user-profile-widget.test.ts} | 0 7 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initComponentsMixins/initUserCustomAttributesMixin.ts rename packages/widgets/user-profile-widget/test/{user-management-widget.test.ts => user-profile-widget.test.ts} (100%) diff --git a/packages/widgets/user-profile-widget/jest.config.js b/packages/widgets/user-profile-widget/jest.config.js index eb76d1d78..27d3e961b 100644 --- a/packages/widgets/user-profile-widget/jest.config.js +++ b/packages/widgets/user-profile-widget/jest.config.js @@ -11,8 +11,8 @@ module.exports = { global: { branches: 0, functions: 12, - lines: 38, - statements: 37, + lines: 36, + statements: 36, }, }, globals: { diff --git a/packages/widgets/user-profile-widget/src/lib/widget/api/sdk/index.ts b/packages/widgets/user-profile-widget/src/lib/widget/api/sdk/index.ts index ae7056d06..c3ee420d0 100644 --- a/packages/widgets/user-profile-widget/src/lib/widget/api/sdk/index.ts +++ b/packages/widgets/user-profile-widget/src/lib/widget/api/sdk/index.ts @@ -1,6 +1,6 @@ +import '@descope/core-js-sdk'; import createWebSdk from '@descope/web-js-sdk'; import { createUserSdk } from './createUserSdk'; -import '@descope/core-js-sdk'; declare const BUILD_VERSION: string; diff --git a/packages/widgets/user-profile-widget/src/lib/widget/api/types.ts b/packages/widgets/user-profile-widget/src/lib/widget/api/types.ts index 71b03677e..dd972c991 100644 --- a/packages/widgets/user-profile-widget/src/lib/widget/api/types.ts +++ b/packages/widgets/user-profile-widget/src/lib/widget/api/types.ts @@ -102,3 +102,12 @@ export type CustomAttr = { EditPermissions: string[]; editable: boolean; }; + +export enum AttributeTypeName { + TEXT = 'text', + NUMBER = 'number', + BOOLEAN = 'boolean', + SINGLE_SELECT = 'singleSelect', + ARRAY = 'array', + DATE = 'date', +} diff --git a/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initComponentsMixins/initUserCustomAttributesMixin.ts b/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initComponentsMixins/initUserCustomAttributesMixin.ts new file mode 100644 index 000000000..19ec74b5b --- /dev/null +++ b/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initComponentsMixins/initUserCustomAttributesMixin.ts @@ -0,0 +1,171 @@ +import { + FlowDriver, + ModalDriver, + UserAttributeDriver, +} from '@descope/sdk-component-drivers'; +import { + compose, + createSingletonMixin, + withMemCache, +} from '@descope/sdk-helpers'; +import { loggerMixin, modalMixin } from '@descope/sdk-mixins'; +import { AttributeTypeName } from '../../../api/types'; +import { getUserCustomAttrs } from '../../../state/selectors'; +import { createFlowTemplate } from '../../helpers'; +import { stateManagementMixin } from '../../stateManagementMixin'; +import { initWidgetRootMixin } from './initWidgetRootMixin'; + +export const initUserCustomAttributesMixin = createSingletonMixin( + (superclass: T) => + class UserCustomAttributesMixinClass extends compose( + stateManagementMixin, + loggerMixin, + initWidgetRootMixin, + modalMixin, + )(superclass) { + // flow Id is key in all maps + #editModals: Record = {}; + + #editFlows: Record = {}; + + #deleteModals: Record = {}; + + #deleteFlows: Record = {}; + + static getFormattedValue(type: string, val: any) { + if (type === AttributeTypeName.DATE && val) { + // to full date time + return new Date(val).toLocaleString(); + } + if (type === AttributeTypeName.BOOLEAN && val !== undefined) { + return !val ? 'False' : 'True'; + } + return (val || '').toString(); + } + + #initEditModalContent(flowId: string) { + this.#editModals[flowId]?.setContent( + createFlowTemplate({ + projectId: this.projectId, + flowId, + baseUrl: this.baseUrl, + baseStaticUrl: this.baseStaticUrl, + }), + ); + this.#editFlows[flowId]?.onSuccess(() => { + this.#editModals[flowId]?.close(); + this.actions.getMe(); + }); + } + + // have 2 init functions for edit and delete modals in order to keep the same standards as the email/phone/name mixin + #initDeleteModalContent(flowId: string) { + this.#deleteModals[flowId]?.setContent( + createFlowTemplate({ + projectId: this.projectId, + flowId, + baseUrl: this.baseUrl, + baseStaticUrl: this.baseStaticUrl, + }), + ); + this.#deleteFlows[flowId]?.onSuccess(() => { + this.#deleteModals[flowId]?.close(); + this.actions.getMe(); + }); + } + + #updateCustomValueUserAttrs = withMemCache( + (userCustomAttributes: ReturnType) => { + const allCustomAttributesComponents = + this.shadowRoot?.querySelectorAll( + 'descope-user-attribute[data-id^="customAttributes."]', + ); + + Array.from(allCustomAttributesComponents).forEach((nodeEle) => { + const attrName = nodeEle.getAttribute('data-id'); + const customAttrName = attrName.replace('customAttributes.', ''); + const type = + nodeEle.getAttribute('data-type') || AttributeTypeName.TEXT; + const val = userCustomAttributes[customAttrName]; + + const compInstance = new UserAttributeDriver(nodeEle, { + logger: this.logger, + }); + + compInstance.value = + UserCustomAttributesMixinClass.getFormattedValue(type, val); + + this.#initEditFlow(nodeEle, customAttrName, compInstance); + this.#initDeleteFlow(nodeEle, customAttrName, compInstance); + }); + }, + ); + + #initEditFlow( + nodeEle: Element, + customAttrName: string, + compInstance: UserAttributeDriver, + ) { + const editFlowId = nodeEle.getAttribute('edit-flow-id'); + if (editFlowId) { + this.#editModals[editFlowId] = this.createModal({ + 'data-id': `edit-${customAttrName}`, + }); + + this.#editFlows[editFlowId] = new FlowDriver( + () => + this.#editModals[editFlowId]?.ele?.querySelector('descope-wc'), + { logger: this.logger }, + ); + this.#editModals[editFlowId].afterClose = + this.#initEditModalContent.bind(this, editFlowId); + + compInstance.onEditClick(() => { + this.#editModals?.[editFlowId]?.open(); + }); + + this.#initEditModalContent(editFlowId); + } + } + + #initDeleteFlow( + nodeEle: Element, + customAttrName: string, + compInstance: UserAttributeDriver, + ) { + const deleteFlowId = nodeEle.getAttribute('delete-flow-id'); + if (deleteFlowId) { + this.#deleteModals[deleteFlowId] = this.createModal({ + 'data-id': `delete-${customAttrName}`, + }); + + this.#deleteFlows[deleteFlowId] = new FlowDriver( + () => + this.#deleteModals[deleteFlowId]?.ele?.querySelector( + 'descope-wc', + ), + { logger: this.logger }, + ); + this.#deleteModals[deleteFlowId].afterClose = + this.#initDeleteModalContent.bind(this, deleteFlowId); + + compInstance.onDeleteClick(() => { + this.#deleteModals?.[deleteFlowId]?.open(); + }); + + this.#initDeleteModalContent(deleteFlowId); + } + } + + async onWidgetRootReady() { + await super.onWidgetRootReady?.(); + + this.#updateCustomValueUserAttrs(getUserCustomAttrs(this.state)); + + this.subscribe( + this.#updateCustomValueUserAttrs.bind(this), + getUserCustomAttrs, + ); + } + }, +); diff --git a/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initMixin.ts b/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initMixin.ts index fd4adcfbb..55f74d65e 100644 --- a/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initMixin.ts +++ b/packages/widgets/user-profile-widget/src/lib/widget/mixins/initMixin/initMixin.ts @@ -1,13 +1,14 @@ import { compose, createSingletonMixin } from '@descope/sdk-helpers'; import { debuggerMixin, themeMixin } from '@descope/sdk-mixins'; +import { flowRedirectUrlMixin } from '../flowRedirectUrlMixin'; import { initAvatarMixin } from './initComponentsMixins/initAvatarMixin'; import { initEmailUserAttrMixin } from './initComponentsMixins/initEmailUserAttrMixin'; +import { initLogoutMixin } from './initComponentsMixins/initLogoutMixin'; import { initNameUserAttrMixin } from './initComponentsMixins/initNameUserAttrMixin'; -import { initPhoneUserAttrMixin } from './initComponentsMixins/initPhoneUserAttrMixin'; import { initPasskeyUserAuthMethodMixin } from './initComponentsMixins/initPasskeyUserAuthMethodMixin'; import { initPasswordUserAuthMethodMixin } from './initComponentsMixins/initPasswordUserAuthMethodMixin'; -import { initLogoutMixin } from './initComponentsMixins/initLogoutMixin'; -import { flowRedirectUrlMixin } from '../flowRedirectUrlMixin'; +import { initPhoneUserAttrMixin } from './initComponentsMixins/initPhoneUserAttrMixin'; +import { initUserCustomAttributesMixin } from './initComponentsMixins/initUserCustomAttributesMixin'; export const initMixin = createSingletonMixin( (superclass: T) => @@ -16,6 +17,7 @@ export const initMixin = createSingletonMixin( debuggerMixin, themeMixin, flowRedirectUrlMixin, // This mixin must be before all other mixins that loads flows + initUserCustomAttributesMixin, initEmailUserAttrMixin, initAvatarMixin, initNameUserAttrMixin, diff --git a/packages/widgets/user-profile-widget/src/lib/widget/state/selectors.ts b/packages/widgets/user-profile-widget/src/lib/widget/state/selectors.ts index 1cd8c16aa..3d845c95f 100644 --- a/packages/widgets/user-profile-widget/src/lib/widget/state/selectors.ts +++ b/packages/widgets/user-profile-widget/src/lib/widget/state/selectors.ts @@ -17,3 +17,8 @@ export const getIsPhoneVerified = createSelector( ); export const getHasPasskey = createSelector(getMe, (me) => me.webauthn); export const getHasPassword = createSelector(getMe, (me) => me.password); + +export const getUserCustomAttrs = createSelector( + getMe, + (me) => me.customAttributes as Record, +); diff --git a/packages/widgets/user-profile-widget/test/user-management-widget.test.ts b/packages/widgets/user-profile-widget/test/user-profile-widget.test.ts similarity index 100% rename from packages/widgets/user-profile-widget/test/user-management-widget.test.ts rename to packages/widgets/user-profile-widget/test/user-profile-widget.test.ts