Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@eyeseetea/training-app",
"description": "Training App",
"version": "1.7.1",
"version": "1.7.2",
"license": "GPL-3.0",
"author": "EyeSeeTea team",
"main": "index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import i18n from "../../utils/i18n";
import { ScrollableContainer } from "./ScrollableContainer";
import { ActionButton } from "../../webapp/components/action-button/ActionButton";
import { useDrawerCollapseMode } from "./hooks/useDrawerCollapseMode";
import { NotificationBadgeState } from "./hooks/useContentChangeIndicator";
import { NotificationBadge } from "./components/NotificationBadge";

const DRAWER_COLLAPSED_WIDTH = 40;

Expand All @@ -24,6 +26,7 @@ type SideDrawerProps = {
triggerKey: string;
containerConfig: SideBarConfig;
drawerContent: React.ReactNode;
badgeProps?: NotificationBadgeState;
};

export const InteractiveTrainingDrawer: React.FC<SideDrawerProps> = props => {
Expand All @@ -38,6 +41,7 @@ export const InteractiveTrainingDrawer: React.FC<SideDrawerProps> = props => {
triggerKey,
containerConfig,
drawerContent,
badgeProps,
} = props;

const isRight = containerConfig.position === "right";
Expand Down Expand Up @@ -95,6 +99,7 @@ export const InteractiveTrainingDrawer: React.FC<SideDrawerProps> = props => {
tooltip={toggleTooltip}
tooltipPlacement={toggleTooltipPlacement}
isMinimized={isMinimized}
badgeProps={showMini && isMinimized ? badgeProps : undefined}
>
{isMinimized && <HelpText>{i18n.t("help")}</HelpText>}
</DrawerToggleButton>
Expand All @@ -114,6 +119,7 @@ export const InteractiveTrainingDrawer: React.FC<SideDrawerProps> = props => {
<ActionButtonContainer>
<ActionButton onClick={showTraining} {...buttonPosition}>
<HelpButton>?</HelpButton>
<NotificationBadge {...badgeProps} />
</ActionButton>
</ActionButtonContainer>
)}
Expand All @@ -127,6 +133,7 @@ type DrawerToggleButtonProps = PropsWithChildren<{
tooltip: string;
tooltipPlacement: "left" | "right";
isMinimized: boolean;
badgeProps?: NotificationBadgeState;
}>;

const DrawerToggleButton: React.FC<DrawerToggleButtonProps> = ({
Expand All @@ -135,16 +142,22 @@ const DrawerToggleButton: React.FC<DrawerToggleButtonProps> = ({
tooltip,
tooltipPlacement,
isMinimized,
badgeProps,
children,
}) => (
<div onClick={onClick}>
<DrawerToggleContainer onClick={onClick}>
<HeaderButton text={tooltip} placement={tooltipPlacement} isMinimized={isMinimized}>
<Icon />
{children}
</HeaderButton>
</div>
<NotificationBadge {...badgeProps} />
</DrawerToggleContainer>
);

const DrawerToggleContainer = styled.div`
position: relative;
`;

const Content = styled(ScrollableContainer)`
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -242,6 +255,8 @@ const closedStyles = css`
`;

const ActionButtonContainer = styled.div`
position: relative;

.MuiFab-root {
padding: 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { Modal, ModalContent } from "../../webapp/components/modal";
import { ScrollableContainer } from "./ScrollableContainer";
import { ActionButton } from "../../webapp/components/action-button/ActionButton";
import { DialogConfig } from "../../domain/entities/Config";
import { NotificationBadgeState } from "./hooks/useContentChangeIndicator";
import { NotificationBadge } from "./components/NotificationBadge";

type InteractiveTrainingModalProps = React.ComponentProps<typeof Modal> & {
triggerKey: string;
showTraining: () => void;
containerConfig: DialogConfig;
badgeProps?: NotificationBadgeState;
};

export const InteractiveTrainingModal: React.FC<InteractiveTrainingModalProps> = props => {
const { children, triggerKey, showTraining, containerConfig, ...modalProps } = props;
const { children, triggerKey, showTraining, containerConfig, badgeProps, ...modalProps } = props;

const position =
containerConfig.buttonPosition === "top-right"
Expand All @@ -36,6 +39,7 @@ export const InteractiveTrainingModal: React.FC<InteractiveTrainingModalProps> =
<ActionButtonContainer hidden={!modalProps.minimized}>
<ActionButton onClick={showTraining} {...position}>
<HelpButton>?</HelpButton>
<NotificationBadge {...badgeProps} />
</ActionButton>
</ActionButtonContainer>
</>
Expand All @@ -57,6 +61,7 @@ const StyledModal = styled(Modal)`
`;

const ActionButtonContainer = styled.div<{ hidden: boolean }>`
position: relative;
visibility: ${({ hidden }) => (hidden ? "hidden" : "visible")};

.MuiFab-root {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { createContext, PropsWithChildren, useMemo } from "react";
import React, { createContext, PropsWithChildren, useCallback, useMemo } from "react";

import { TrainingModulePage } from "../../domain/entities/TrainingModule";
import { Maybe } from "../../types/utils";
Expand All @@ -13,6 +13,7 @@ import {
} from "./hooks/useInteractiveTraining";
import { TrainingLanding } from "./TrainingLanding";
import { useScrollableContainerKey } from "./hooks/useScrollableContainerKey";
import { useContentChangeIndicator } from "./hooks/useContentChangeIndicator";

const trainingEventKinds = ["click", "focus", "section"] as const;
type TrainingEventKind = typeof trainingEventKinds[number];
Expand All @@ -30,6 +31,7 @@ type TutorialModuleProps = PropsWithChildren<{
events?: TrainingEventKind[];
highlightElementsWithBindings?: boolean;
trainingAppKey?: string;
showContentChangeIndicator?: boolean;
}>;

const defaultAppKey = "Training-App";
Expand All @@ -41,6 +43,7 @@ export const InteractiveTrainingProvider: React.FC<TutorialModuleProps> = props
events = [...trainingEventKinds],
highlightElementsWithBindings,
trainingAppKey = defaultAppKey,
showContentChangeIndicator = true,
children,
} = props;

Expand Down Expand Up @@ -73,6 +76,18 @@ export const InteractiveTrainingProvider: React.FC<TutorialModuleProps> = props
loadedModule: moduleHandling.loadedModule,
});

const { badgeProps, clearIndicator } = useContentChangeIndicator({
targetIds,
textContent,
isMinimized,
enabled: showContentChangeIndicator,
});

const handleShowTraining = useCallback(() => {
clearIndicator();
showTraining();
}, [clearIndicator, showTraining]);

const containerClass = `training-scope ${highlightElementsWithBindings ? "highlight-training-elements" : ""}`;

const contextValue = useMemo(() => ({ pages, trigger, events }), [pages, trigger, events]);
Expand All @@ -87,7 +102,8 @@ export const InteractiveTrainingProvider: React.FC<TutorialModuleProps> = props
triggerKey={triggerKey}
isMinimized={isMinimized}
onMinimize={minimizeTraining}
showTraining={showTraining}
showTraining={handleShowTraining}
badgeProps={badgeProps}
settingsAccess={settingsAccess}
goBack={onGoBack}
goHome={onGoHome}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InteractiveTrainingModal } from "./InteractiveTrainingModal";
import { SettingsAccess } from "./hooks/useInteractiveTraining";
import { MarkdownViewer } from "../../webapp/components/markdown-viewer/MarkdownViewer";
import { InteractiveTrainingDrawer } from "./InteractiveTrainingDrawer";
import { NotificationBadgeState } from "./hooks/useContentChangeIndicator";

type TrainingContainerProps = {
containerConfig: ContainerConfig;
Expand All @@ -19,6 +20,7 @@ type TrainingContainerProps = {
goBack?: () => void;
goHome?: () => void;
isLoading?: boolean;
badgeProps?: NotificationBadgeState;
};

export const TrainingContainer: React.FC<TrainingContainerProps> = props => {
Expand All @@ -32,6 +34,7 @@ export const TrainingContainer: React.FC<TrainingContainerProps> = props => {
goHome,
goBack,
isLoading = true,
badgeProps,
...containerProps
} = props;

Expand All @@ -53,6 +56,7 @@ export const TrainingContainer: React.FC<TrainingContainerProps> = props => {
onBack={goBack}
onSettings={settingsAccess.hasAccess ? onSettings : undefined}
containerConfig={containerConfig}
badgeProps={badgeProps}
drawerContent={<TrainingContainerContent content={content} defaultContent={defaultContent} />}
>
{children}
Expand All @@ -69,6 +73,7 @@ export const TrainingContainer: React.FC<TrainingContainerProps> = props => {
onGoBack={goBack}
onSettings={settingsAccess.hasAccess ? onSettings : undefined}
containerConfig={containerConfig}
badgeProps={badgeProps}
>
<TrainingContainerContent content={content} defaultContent={defaultContent} />
</InteractiveTrainingModal>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import styled, { css, keyframes } from "styled-components";

const pulseAnimation = keyframes`
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.4);
opacity: 0.8;
}
`;

type NotificationBadgeProps = {
isVisible?: boolean;
isPulsing?: boolean;
};

export const NotificationBadge = styled.div<NotificationBadgeProps>`
position: absolute;
top: -4px;
right: -4px;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #f5a623;
box-shadow: 0 0 4px rgba(245, 166, 35, 0.6);
transition: opacity 200ms ease;
z-index: 9999;

${({ isVisible }) =>
isVisible
? css`
opacity: 1;
`
: css`
opacity: 0;
pointer-events: none;
`}

${({ isPulsing }) =>
isPulsing &&
css`
animation: ${pulseAnimation} 350ms ease 3;
`}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

export type IndicatorState = "hidden" | "visible" | "pulsing";

export type UseContentChangeIndicatorProps = {
targetIds: string[];
textContent: string;
isMinimized: boolean;
enabled: boolean;
};

export type NotificationBadgeState = {
isVisible: boolean;
isPulsing: boolean;
};

export type UseContentChangeIndicatorResult = {
indicatorState: IndicatorState;
badgeProps: NotificationBadgeState;
clearIndicator: () => void;
};

export function useContentChangeIndicator(props: UseContentChangeIndicatorProps): UseContentChangeIndicatorResult {
const { targetIds, textContent, isMinimized, enabled } = props;

const [indicatorState, setIndicatorState] = useState<IndicatorState>("hidden");

const previousTargetIdsKey = useRef<string>("");
const lastSeenTargetKey = useRef<string>("");

const currentTargetKey = useMemo(() => targetIds.join(","), [targetIds]);
const hasContent = useMemo(() => textContent.trim().length > 0, [textContent]);

useEffect(() => {
if (!enabled) {
setIndicatorState("hidden");
return;
}

// reset on default content
if (!hasContent) {
setIndicatorState("hidden");
lastSeenTargetKey.current = "";
previousTargetIdsKey.current = "";
return;
}

if (!isMinimized) {
setIndicatorState("hidden");
lastSeenTargetKey.current = currentTargetKey;
previousTargetIdsKey.current = currentTargetKey;
return;
}

const contentChanged = currentTargetKey !== previousTargetIdsKey.current && currentTargetKey !== "";

if (contentChanged) {
const seenByUser = currentTargetKey === lastSeenTargetKey.current;
previousTargetIdsKey.current = currentTargetKey;

if (!seenByUser) {
setIndicatorState("pulsing");
}
}
}, [currentTargetKey, isMinimized, enabled, hasContent]);

// transition pulsing
useEffect(() => {
if (indicatorState === "pulsing") {
//1050 -> pulsing animation 350 x 3
const timeoutId = setTimeout(() => {
setIndicatorState("visible");
}, 1050);

return () => clearTimeout(timeoutId);
}
return undefined;
}, [indicatorState]);

const clearIndicator = useCallback(() => {
setIndicatorState("hidden");
lastSeenTargetKey.current = currentTargetKey;
}, [currentTargetKey]);

return {
indicatorState,
badgeProps: {
isVisible: indicatorState !== "hidden",
isPulsing: indicatorState === "pulsing",
},
clearIndicator,
};
}
2 changes: 1 addition & 1 deletion src/tutorial-module/interactive-training/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function getSectionPageIds(currentUrl: string, pages: TrainingModulePage[
}

export function generateSettingsUrl(baseUrl: string, appKey: string) {
return `${generateTrainingAppBaseUrl(baseUrl, appKey)}/index.html#/settings`;
return `${generateTrainingAppBaseUrl(baseUrl, appKey)}/index.html#/settings?showInteractiveTrainingConfig`;
}

export function generateTrainingAppBaseUrl(baseUrl: string, appKey: string) {
Expand Down
Loading
Loading