Skip to content

Commit

Permalink
fix: Surface elements (Modals, Contextual Bars) fail to respond when …
Browse files Browse the repository at this point in the history
…triggered by app submit events (#32073)

Co-authored-by: Tasso Evangelista <[email protected]>
  • Loading branch information
AllanPazRibeiro and tassoevan authored Apr 18, 2024
1 parent e90954e commit c0d54d7
Show file tree
Hide file tree
Showing 7 changed files with 216 additions and 123 deletions.
6 changes: 6 additions & 0 deletions .changeset/young-candles-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/ui-contexts': minor
'@rocket.chat/meteor': minor
---

Fixed an issue affecting the update modal/contextual bar by apps when it comes to error handling and regular surface update
95 changes: 67 additions & 28 deletions apps/meteor/app/ui-message/client/ActionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { Emitter } from '@rocket.chat/emitter';
import { Random } from '@rocket.chat/random';
import type { RouterContext, IActionManager } from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import { t } from 'i18next';
import type { ContextType } from 'react';
import { lazy } from 'react';

import * as banners from '../../../client/lib/banners';
import { imperativeModal } from '../../../client/lib/imperativeModal';
import { dispatchToastMessage } from '../../../client/lib/toast';
import { exhaustiveCheck } from '../../../lib/utils/exhaustiveCheck';
import { sdk } from '../../utils/client/lib/SDKClient';
import { UiKitTriggerTimeoutError } from './UiKitTriggerTimeoutError';

Expand All @@ -20,7 +23,7 @@ export class ActionManager implements IActionManager {

protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>();

protected triggersId = new Map<string, string | undefined>();
protected appIdByTriggerId = new Map<string, string | undefined>();

protected viewInstances = new Map<
string,
Expand All @@ -35,8 +38,8 @@ export class ActionManager implements IActionManager {
public constructor(protected router: ContextType<typeof RouterContext>) {}

protected invalidateTriggerId(id: string) {
const appId = this.triggersId.get(id);
this.triggersId.delete(id);
const appId = this.appIdByTriggerId.get(id);
this.appIdByTriggerId.delete(id);
return appId;
}

Expand Down Expand Up @@ -66,41 +69,75 @@ export class ActionManager implements IActionManager {

public generateTriggerId(appId: string | undefined) {
const triggerId = Random.id();
this.triggersId.set(triggerId, appId);
this.appIdByTriggerId.set(triggerId, appId);
setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT);
return triggerId;
}

public async emitInteraction(appId: string, userInteraction: DistributiveOmit<UiKit.UserInteraction, 'triggerId'>) {
this.notifyBusy();

const triggerId = this.generateTriggerId(appId);

let timeout: ReturnType<typeof setTimeout> | undefined;
return this.runWithTimeout(
async () => {
let interaction: UiKit.ServerInteraction | undefined;

try {
interaction = (await sdk.rest.post(`/apps/ui.interaction/${appId}`, {
...userInteraction,
triggerId,
})) as UiKit.ServerInteraction;

this.handleServerInteraction(interaction);
} finally {
switch (userInteraction.type) {
case 'viewSubmit':
if (!!interaction && !['errors', 'modal.update', 'contextual_bar.update'].includes(interaction.type))
this.disposeView(userInteraction.viewId);
break;

case 'viewClosed':
if (!!interaction && interaction.type !== 'errors') this.disposeView(userInteraction.payload.viewId);
break;
}
}
},
{ triggerId, appId, ...('viewId' in userInteraction ? { viewId: userInteraction.viewId } : {}) },
);
}

protected async runWithTimeout<T>(task: () => Promise<T>, details: { triggerId: string; appId: string; viewId?: string }) {
this.notifyBusy();

await Promise.race([
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new UiKitTriggerTimeoutError('Timeout', { triggerId, appId })), ActionManager.TRIGGER_TIMEOUT);
}),
sdk.rest
.post(`/apps/ui.interaction/${appId}`, {
...userInteraction,
triggerId,
})
.then((interaction) => this.handleServerInteraction(interaction)),
]).finally(() => {
if (timeout) clearTimeout(timeout);
let timer: ReturnType<typeof setTimeout> | undefined;

try {
const taskPromise = task();
const timeoutPromise = new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new UiKitTriggerTimeoutError('Timeout', details));
}, ActionManager.TRIGGER_TIMEOUT);
});

return await Promise.race([taskPromise, timeoutPromise]);
} catch (error) {
if (error instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
if (details.viewId) {
this.disposeView(details.viewId);
}
}
} finally {
if (timer) clearTimeout(timer);
this.notifyIdle();
});
}
}

public handleServerInteraction(interaction: UiKit.ServerInteraction) {
public handleServerInteraction(interaction: UiKit.ServerInteraction): UiKit.ServerInteraction['type'] | undefined {
const { triggerId } = interaction;

if (!this.triggersId.has(triggerId)) {
return;
}

const appId = this.invalidateTriggerId(triggerId);
if (!appId) {
return;
Expand Down Expand Up @@ -162,8 +199,7 @@ export class ActionManager implements IActionManager {

case 'banner.close': {
const { viewId } = interaction;
this.viewInstances.get(viewId)?.close();

this.disposeView(viewId);
break;
}

Expand All @@ -175,9 +211,12 @@ export class ActionManager implements IActionManager {

case 'contextual_bar.close': {
const { view } = interaction;
this.viewInstances.get(view.id)?.close();
this.disposeView(view.id);
break;
}

default:
exhaustiveCheck(interaction);
}

return interaction.type;
Expand Down
4 changes: 0 additions & 4 deletions apps/meteor/client/views/banners/UiKitBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ const UiKitBanner = ({ initialView }: UiKitBannerProps) => {
})
.catch((error) => {
dispatchToastMessage({ type: 'error', message: error });
return Promise.reject(error);
})
.finally(() => {
actionManager.disposeView(view.viewId);
});
});

Expand Down
78 changes: 33 additions & 45 deletions apps/meteor/client/views/modal/uikit/UiKitModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { UiKitContext } from '@rocket.chat/fuselage-ui-kit';
import { MarkupInteractionContext } from '@rocket.chat/gazzodown';
import type * as UiKit from '@rocket.chat/ui-kit';
Expand All @@ -22,59 +22,47 @@ const UiKitModal = ({ initialView }: UiKitModalProps) => {
const { view, errors, values, updateValues, state } = useUiKitView(initialView);
const contextValue = useModalContextValue({ view, values, updateValues });

const handleSubmit = useMutableCallback((e: FormEvent) => {
const handleSubmit = useEffectEvent((e: FormEvent) => {
preventSyntheticEvent(e);
void actionManager
.emitInteraction(view.appId, {
type: 'viewSubmit',
payload: {
view: {
...view,
state,
},
void actionManager.emitInteraction(view.appId, {
type: 'viewSubmit',
payload: {
view: {
...view,
state,
},
viewId: view.id,
})
.finally(() => {
actionManager.disposeView(view.id);
});
},
viewId: view.id,
});
});

const handleCancel = useMutableCallback((e: FormEvent) => {
const handleCancel = useEffectEvent((e: FormEvent) => {
preventSyntheticEvent(e);
void actionManager
.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
isCleared: false,
void actionManager.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
})
.finally(() => {
actionManager.disposeView(view.id);
});
isCleared: false,
},
});
});

const handleClose = useMutableCallback(() => {
void actionManager
.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
isCleared: true,
const handleClose = useEffectEvent(() => {
void actionManager.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
})
.finally(() => {
actionManager.disposeView(view.id);
});
isCleared: true,
},
});
});

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Avatar, Box, Button, ButtonGroup, ContextualbarFooter, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import {
UiKitComponent,
UiKitContextualBar as UiKitContextualBarSurfaceRender,
Expand Down Expand Up @@ -32,63 +32,51 @@ const UiKitContextualBar = ({ initialView }: UiKitContextualBarProps): JSX.Eleme

const { closeTab } = useRoomToolbox();

const handleSubmit = useMutableCallback((e: FormEvent) => {
const handleSubmit = useEffectEvent((e: FormEvent) => {
preventSyntheticEvent(e);
closeTab();
void actionManager
.emitInteraction(view.appId, {
type: 'viewSubmit',
payload: {
view: {
...view,
state,
},
void actionManager.emitInteraction(view.appId, {
type: 'viewSubmit',
payload: {
view: {
...view,
state,
},
viewId: view.id,
})
.finally(() => {
actionManager.disposeView(view.id);
});
},
viewId: view.id,
});
});

const handleCancel = useMutableCallback((e: UIEvent) => {
const handleCancel = useEffectEvent((e: UIEvent) => {
preventSyntheticEvent(e);
closeTab();
void actionManager
.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
isCleared: false,
void actionManager.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
})
.finally(() => {
actionManager.disposeView(view.id);
});
isCleared: false,
},
});
});

const handleClose = useMutableCallback((e: UIEvent) => {
const handleClose = useEffectEvent((e: UIEvent) => {
preventSyntheticEvent(e);
closeTab();
void actionManager
.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
isCleared: true,
void actionManager.emitInteraction(view.appId, {
type: 'viewClosed',
payload: {
viewId: view.id,
view: {
...view,
state,
},
})
.finally(() => {
actionManager.disposeView(view.id);
});
isCleared: true,
},
});
});

return (
Expand Down
Loading

0 comments on commit c0d54d7

Please sign in to comment.