diff --git a/client/src/Core/Components/AssignmentUpdateFailureMessageBar.tsx b/client/src/Core/Components/AssignmentUpdateFailureMessageBar.tsx new file mode 100644 index 00000000..e23fb35c --- /dev/null +++ b/client/src/Core/Components/AssignmentUpdateFailureMessageBar.tsx @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + *--------------------------------------------------------------------------------------------*/ + +import { AnimationClassNames, IMessageBarStyles, MessageBar, MessageBarType, styled } from '@fluentui/react'; +import { useObserver } from 'mobx-react-lite'; +import React from 'react'; +import { useStore } from '../../Stores/Core'; +import { ServiceError } from '../Utils/Axios/ServiceError'; +import { themedClassNames } from '../Utils/FluentUI'; +import { IStylesOnly, IThemeOnlyProps } from '../Utils/FluentUI/typings.fluent-ui'; + +type Store = 'assignment' | 'links' | 'learn-content'; + +const getErrorMessage = (stores: Store[], error: ServiceError | null) => { + if (error === null) { + return undefined; + } + + const getStoreSpecificMessage = (store: Store) => { + switch (store) { + case 'links': return 'links'; + case 'learn-content': return 'Microsoft Learn content'; + case 'assignment': return 'description and deadline'; + } + }; + + let storesWithError = stores.map(store => getStoreSpecificMessage(store)).join(', '); + let message = `Sorry! An error was encountered, and we could not sync the assignment's ${storesWithError} properly. Head to the Preview page to see the saved state of the assignment. `; + + switch (error) { + case 'unauthorized': + return message + 'It seems like you do not have sufficient permissions to perform this action.'; + case 'not found': + return message + 'We could not find what you were looking for. Please contact the server administrator or a teacher.' + case 'bad request': + return message + 'The server could not process your request.'; + case 'internal error': + case 'other': + return message + 'The server encountered an internal error and was unable to complete your request. Please contact the server administrator.' + } +}; + +const AssignmentUpdateFailureMessageBarInner = ({ styles }: IStylesOnly): JSX.Element | null => { + const assignmentStore = useStore('assignmentStore'); + const assignmentLinksStore = useStore('assignmentLinksStore'); + const learnStore = useStore('microsoftLearnStore'); + const classes = themedClassNames(styles); + + return useObserver(() => { + const learnStoreError = learnStore.itemsInErrorState.length !== 0 && !learnStore.serviceCallsInProgress && learnStore.hasServiceError ? learnStore.hasServiceError : null; + const linkStoreError = !assignmentLinksStore.isSynced && assignmentLinksStore.serviceCallInProgress === 0 && assignmentLinksStore.hasServiceError ? assignmentLinksStore.hasServiceError : null; + const assignmentError = !assignmentStore.isSynced && assignmentStore.serviceCallInProgress === 0 && assignmentStore.hasServiceError ? assignmentStore.hasServiceError : null; + + let errorMessageMap: Map = new Map(); + + const getExistingStoresErrorMap = (err: ServiceError): Store[] => errorMessageMap.get(err) || []; + + if (linkStoreError) { + errorMessageMap.set(linkStoreError, [...getExistingStoresErrorMap(linkStoreError), 'links']); + } + if (learnStoreError) { + errorMessageMap.set(learnStoreError, [...getExistingStoresErrorMap(learnStoreError), 'learn-content']); + } + if (assignmentError) { + errorMessageMap.set(assignmentError, [...getExistingStoresErrorMap(assignmentError), 'assignment']); + } + + if (learnStoreError || linkStoreError || assignmentError) { + return ( + <> + {[...errorMessageMap].map(([serviceError, stores]) => { + return ( + + {getErrorMessage(stores, serviceError)} + + ); + })} + + ); + } else { + return null; + } + }); +}; + +const assignmentUpdateFailureMessageBarStyles = ({ theme }: IThemeOnlyProps): Partial => ({ + root: [AnimationClassNames.fadeIn500] +}); + +export const AssignmentUpdateFailureMessageBar = styled(AssignmentUpdateFailureMessageBarInner, assignmentUpdateFailureMessageBarStyles); diff --git a/client/src/Core/Components/MainLayout.tsx b/client/src/Core/Components/MainLayout.tsx index e50e1303..3862ac17 100644 --- a/client/src/Core/Components/MainLayout.tsx +++ b/client/src/Core/Components/MainLayout.tsx @@ -17,6 +17,7 @@ import learnLogo from '../../Assets/icon_learn_062020.png'; import { useQueryValue } from '../Hooks'; import { ErrorPage } from './ErrorsPage'; import { NavigationControlHeader } from './NavigationControlHeader'; +import { AssignmentUpdateFailureMessageBar } from './AssignmentUpdateFailureMessageBar'; type MainLayoutStyles = SimpleComponentStyles<'root' | 'spinner' | 'content'>; @@ -52,11 +53,15 @@ const MainLayoutInner = ({ styles }: IStylesOnly): JSX.Element
{usersStore.userDetails.role === 'teacher' && !asStudent ? ( <> + ) : ( - + <> + + + )}
)} diff --git a/client/src/Core/Components/NavigationControlHeader.tsx b/client/src/Core/Components/NavigationControlHeader.tsx index 49b9ac24..2dd4b999 100644 --- a/client/src/Core/Components/NavigationControlHeader.tsx +++ b/client/src/Core/Components/NavigationControlHeader.tsx @@ -3,40 +3,90 @@ * Licensed under the MIT License. *--------------------------------------------------------------------------------------------*/ -import { FontSizes, FontWeights, mergeStyles, styled } from '@fluentui/react'; +import { SpinnerSize } from '@fluentui/react'; +import { Spinner } from '@fluentui/react'; +import { FontSizes, FontWeights, mergeStyles, MessageBarType, styled } from '@fluentui/react'; import { useObserver } from 'mobx-react-lite'; import React from 'react'; import { PublishControlArea } from '../../Features/PublishAssignment/PublishControlArea'; -import { PublishSuccessMessageBar } from '../../Features/PublishAssignment/PublishSuccessMessageBar'; +import { + PublishSuccessMessageBar, + PublishSuccessMessageBarProps +} from '../../Features/PublishAssignment/PublishSuccessMessageBar'; import { useStore } from '../../Stores/Core'; import { themedClassNames } from '../Utils/FluentUI'; import { IStylesOnly, IThemeOnlyProps, SimpleComponentStyles } from '../Utils/FluentUI/typings.fluent-ui'; import { stickyHeaderStyle } from './Common/StickyHeaderStyle'; import * as NavBarBase from './Navbar'; -type NavigationControlHeaderStyles = SimpleComponentStyles<'assignmentTitle' | 'navAndControlArea'>; +type NavigationControlHeaderStyles = SimpleComponentStyles<'assignmentTitle' | 'navAndControlArea' | 'header'>; function NavigationControlHeaderInner({ styles }: IStylesOnly): JSX.Element { const assignmentStore = useStore('assignmentStore'); + const assignmentLinksStore = useStore('assignmentLinksStore'); + const learnStore = useStore('microsoftLearnStore'); const classes = themedClassNames(styles); - return useObserver(() => ( - <> - {assignmentStore.assignment?.name} -
- - -
- - - )); + + return useObserver(() => { + const learnStoreCallsInProgress = + !learnStore.isLoadingCatalog && + (learnStore.serviceCallsInProgress || learnStore.clearCallInProgress || learnStore.clearCallsToMake) + ? 1 + : 0; + const isCallInProgress = + learnStoreCallsInProgress + assignmentLinksStore.serviceCallInProgress + assignmentStore.serviceCallInProgress > 0; + const publishStatusMessageBarProps: PublishSuccessMessageBarProps = + assignmentStore.pushlishStatusChangeError === 'unauthorized' + ? { + messageBarType: MessageBarType.error, + message: 'Sorry, but it seems like you do not have sufficient permissions to perform this action.', + showMessage: assignmentStore.pushlishStatusChangeError === 'unauthorized' + } + : assignmentStore.pushlishStatusChangeError !== null + ? { + messageBarType: MessageBarType.error, + message: + assignmentStore.assignment?.publishStatus !== 'Published' + ? 'Something went wrong! Could not publish the assignment' + : 'Something went wrong! Could not switch to edit mode', + showMessage: assignmentStore.pushlishStatusChangeError !== null + } + : { + messageBarType: MessageBarType.success, + message: 'Your assignment was published successfully', + showMessage: + assignmentStore.assignment?.publishStatus === 'Published' && + assignmentStore.isChangingPublishState === false + }; + + return ( + <> +
+ {assignmentStore.assignment?.name} + {isCallInProgress && } +
+
+ + +
+ + + ); + }); } const navigationControlHeaderStyle = ({ theme }: IThemeOnlyProps): NavigationControlHeaderStyles => { return { + header: [ + { + display: 'flex', + justifyContent: 'space-between', + paddingRight: `calc(${theme.spacing.l1} * 1.6)`, + paddingLeft: `calc(${theme.spacing.l1} * 1.6)`, + paddingBottom: `calc(${theme.spacing.l1} * 0.5)`, + paddingTop: `calc(${theme.spacing.l1} * 1.5)` + } + ], assignmentTitle: [ { fontSize: FontSizes.xLargePlus, @@ -44,9 +94,6 @@ const navigationControlHeaderStyle = ({ theme }: IThemeOnlyProps): NavigationCon color: theme.palette.neutralPrimary, backgroundColor: theme.palette.neutralLighterAlt, lineHeight: FontSizes.xxLarge, - paddingLeft: `calc(${theme.spacing.l1} * 1.6)`, - paddingBottom: `calc(${theme.spacing.l1} * 0.5)`, - paddingTop: `calc(${theme.spacing.l1} * 1.5)` } ], diff --git a/client/src/Core/Utils/Axios/ServiceError.ts b/client/src/Core/Utils/Axios/ServiceError.ts index 8bb6d69f..1475a849 100644 --- a/client/src/Core/Utils/Axios/ServiceError.ts +++ b/client/src/Core/Utils/Axios/ServiceError.ts @@ -3,4 +3,4 @@ * Licensed under the MIT License. *--------------------------------------------------------------------------------------------*/ -export type ServiceError = 'unauthorized' | 'internal error' | 'other' | 'not found'; +export type ServiceError = 'unauthorized' | 'internal error' | 'other' | 'not found' | 'bad request'; diff --git a/client/src/Core/Utils/Axios/safeData.ts b/client/src/Core/Utils/Axios/safeData.ts index b60be713..17630140 100644 --- a/client/src/Core/Utils/Axios/safeData.ts +++ b/client/src/Core/Utils/Axios/safeData.ts @@ -9,7 +9,8 @@ import { ServiceError } from './ServiceError'; const errorNumberToEnumMap: Map = new Map([ [500, 'internal error'], [401, 'unauthorized'], - [404, 'not found'] + [404, 'not found'], + [400, 'bad request'] ]); export type WithError = T & { error?: ServiceError }; diff --git a/client/src/Dtos/Assignment.dto.ts b/client/src/Dtos/Assignment.dto.ts index 40dc6bf0..0a552541 100644 --- a/client/src/Dtos/Assignment.dto.ts +++ b/client/src/Dtos/Assignment.dto.ts @@ -7,7 +7,7 @@ import { PlatformPersonalizationDto } from './PlatformPersonalization.dto'; export interface AssignmentDto { id: string; - deadline: Date; + deadline: Date | null; courseName: string; name: string; description: string; diff --git a/client/src/Features/AssignmentLinks/AssignmentLinkDisplayItemActionArea.tsx b/client/src/Features/AssignmentLinks/AssignmentLinkDisplayItemActionArea.tsx index da546ccc..44731545 100644 --- a/client/src/Features/AssignmentLinks/AssignmentLinkDisplayItemActionArea.tsx +++ b/client/src/Features/AssignmentLinks/AssignmentLinkDisplayItemActionArea.tsx @@ -20,6 +20,7 @@ import { themedClassNames } from '../../Core/Utils/FluentUI'; import { useStore } from '../../Stores/Core'; import { AssignmentLink } from '../../Models/AssignmentLink.model'; import { commonDialogContentProps, DIALOG_MIN_WIDTH } from '../../Core/Components/Common/Dialog/EdnaDialogStyles'; +import { useObserver } from 'mobx-react-lite'; interface AssignmentLinkDisplayItemActionAreaProps { toggleEditMode: () => void; @@ -42,10 +43,18 @@ const AssignmentLinkDisplayItemActionAreaInner = ({ title: 'Delete Link' }; - return ( + return useObserver(() => (
- - setIsDialogOpen(true)}> + + setIsDialogOpen(true)} + disabled={assignmentLinksStore.addOrUpdateCallInProgress.includes(link.id)} + >
- ); + )) }; const assignmentLinkDisplayItemActionAreaStyles = ({ diff --git a/client/src/Features/AssignmentLinks/AssignmentLinksList.tsx b/client/src/Features/AssignmentLinks/AssignmentLinksList.tsx index 18a3920d..e49c0ee2 100644 --- a/client/src/Features/AssignmentLinks/AssignmentLinksList.tsx +++ b/client/src/Features/AssignmentLinks/AssignmentLinksList.tsx @@ -14,6 +14,7 @@ import { AssignmentLinkItemShimmer } from './AssignmentLinkItemShimmer'; interface AssignmentLinksListProps { disableEdit?: boolean; + showSynced?: boolean; } export type AssignmentLinksListStyles = SimpleComponentStyles<'root' | 'itemsDividerSeparator'>; type AssignmentLinksListStylesProps = { @@ -22,10 +23,13 @@ type AssignmentLinksListStylesProps = { const AssignmentLinksListInner = ({ styles, - disableEdit + disableEdit, + showSynced }: AssignmentLinksListProps & IStylesOnly): JSX.Element => { const assignmentLinksStore = useStore('assignmentLinksStore'); + const assignmentLinksToDisplay = showSynced? assignmentLinksStore.syncedAssignmentLinks : assignmentLinksStore.assignmentLinks; + const classes = themedClassNames(styles); return useObserver(() => { @@ -35,7 +39,7 @@ const AssignmentLinksListInner = ({ ) : ( <> - {assignmentLinksStore.assignmentLinks.map((link, index) => ( + { assignmentLinksToDisplay.map((link, index) => ( {index !== 0 && } diff --git a/client/src/Features/GeneralPage/DeadlineInput.tsx b/client/src/Features/GeneralPage/DeadlineInput.tsx index 20faab92..7525b898 100644 --- a/client/src/Features/GeneralPage/DeadlineInput.tsx +++ b/client/src/Features/GeneralPage/DeadlineInput.tsx @@ -12,6 +12,7 @@ import { InputGroupWrapper } from '../../Core/Components/Common/InputGroupWrappe import { generalPageInputGroupChildrenStyleProps } from './GeneralPageStyles'; import { datePickerBaseStyle } from '../../Core/Components/Common/Inputs/EdnaInputStyles'; import { useStore } from '../../Stores/Core'; +import { useObserver } from 'mobx-react-lite'; type DeadlineInputStyles = Partial; @@ -31,7 +32,7 @@ const DeadlineInputInner = ({ styles }: IStylesOnly): JSX.E const formatDate = (chosenDate?: Date): string => { return chosenDate ? moment(chosenDate).format('MMM DD YYYY') : ''; }; - return ( + return useObserver(() => ( ): JSX.E value={deadline || undefined} /> - ); + )); }; const deadlineInputStyles = ({ theme }: IThemeOnlyProps): DeadlineInputStyles => ({ diff --git a/client/src/Features/GeneralPage/DescriptionInput.tsx b/client/src/Features/GeneralPage/DescriptionInput.tsx index 80172d19..73cc95dc 100644 --- a/client/src/Features/GeneralPage/DescriptionInput.tsx +++ b/client/src/Features/GeneralPage/DescriptionInput.tsx @@ -11,6 +11,7 @@ import { generalPageInputGroupChildrenStyleProps } from './GeneralPageStyles'; import { themedClassNames } from '../../Core/Utils/FluentUI'; import { textFieldBaseStyle } from '../../Core/Components/Common/Inputs/EdnaInputStyles'; import { useStore } from '../../Stores/Core'; +import { useObserver } from 'mobx-react-lite'; type DescriptionInputStyles = Partial; const DescriptionInputInner = ({ styles }: IStylesOnly): JSX.Element => { @@ -21,7 +22,7 @@ const DescriptionInputInner = ({ styles }: IStylesOnly): const combinedStyles = mergeStyleSets(themedClassNames(textFieldBaseStyle), themedClassNames(styles)); - return ( + return useObserver(() => ( ): onChange={(_e, newValue) => setDescription(newValue || '')} /> - ); + )); }; const descriptionInputStyles = ({ theme }: IThemeOnlyProps): DescriptionInputStyles => ({ diff --git a/client/src/Features/MicrosoftLearn/MicrosoftLearnItem.tsx b/client/src/Features/MicrosoftLearn/MicrosoftLearnItem.tsx index dae47589..1d32fb3c 100644 --- a/client/src/Features/MicrosoftLearn/MicrosoftLearnItem.tsx +++ b/client/src/Features/MicrosoftLearn/MicrosoftLearnItem.tsx @@ -86,7 +86,8 @@ const MicrosoftLearnItemInner = ({ ); return useObserver(() => { - const isSelected = !!learnStore.selectedItems?.find(selectedItem => selectedItem.contentUid === item.uid); + const selectedItems = [...learnStore.contentSelectionMap].filter(item => item[1].userState==='selected'); + const isSelected = !!selectedItems?.find(selectedItem => selectedItem[0] === item.uid); const classNames = classNamesFunction()(styles, { theme: getTheme(), productDetails: productCatalogDetails, diff --git a/client/src/Features/MicrosoftLearn/MicrosoftLearnRemoveSelectedItemsButton.tsx b/client/src/Features/MicrosoftLearn/MicrosoftLearnRemoveSelectedItemsButton.tsx index 6053c3ba..03c244cc 100644 --- a/client/src/Features/MicrosoftLearn/MicrosoftLearnRemoveSelectedItemsButton.tsx +++ b/client/src/Features/MicrosoftLearn/MicrosoftLearnRemoveSelectedItemsButton.tsx @@ -20,6 +20,7 @@ import { import { themedClassNames } from '../../Core/Utils/FluentUI'; import { useStore } from '../../Stores/Core'; import { commonDialogContentProps, DIALOG_MIN_WIDTH } from '../../Core/Components/Common/Dialog/EdnaDialogStyles'; +import { ContentSelectionProps } from '../../Stores/MicrosoftLearn.store'; const MicrosoftLearnRemoveSelectedItemsButtonInner = ({ styles @@ -37,13 +38,17 @@ const MicrosoftLearnRemoveSelectedItemsButtonInner = ({ microsoftLearnStore.clearAssignmentLearnContent(); setIsDialogOpen(false); }; + const userStateIsSelected = (item: [string, ContentSelectionProps]) => item[1].userState==='selected'; + const syncedStateIsSelected = (item: [string, ContentSelectionProps]) => item[1].syncedState==='selected'; return ( setIsDialogOpen(true)} - disabled={!microsoftLearnStore.selectedItems || microsoftLearnStore.selectedItems?.length === 0} + disabled={microsoftLearnStore.contentSelectionMap.size===0 + || ([...microsoftLearnStore.contentSelectionMap].filter(userStateIsSelected)?.length === 0 + && [...microsoftLearnStore.contentSelectionMap].filter(syncedStateIsSelected)?.length === 0)} > Clear all Selected Tutorials { - learnStore.removeItemSelection(item.uid); + learnStore.toggleItemSelection(item.uid); }} /> diff --git a/client/src/Features/MicrosoftLearn/MicrosoftLearnSelectedItemsList.tsx b/client/src/Features/MicrosoftLearn/MicrosoftLearnSelectedItemsList.tsx index edd9ab69..8625f8af 100644 --- a/client/src/Features/MicrosoftLearn/MicrosoftLearnSelectedItemsList.tsx +++ b/client/src/Features/MicrosoftLearn/MicrosoftLearnSelectedItemsList.tsx @@ -47,23 +47,24 @@ const MicrosoftLearnSelectedItemsListInner = ({ return useObserver(() => { const classes = themedClassNames(styles); + const selectedItems = [...learnStore.contentSelectionMap].filter(item => item[1].userState==='selected') return (
- {`Selected Tutorials (${learnStore?.selectedItems?.length || '0'})`} + {`Selected Tutorials (${selectedItems.length || '0'})`} - {(!learnStore.selectedItems || learnStore.isLoadingCatalog) && ( + {(learnStore.isLoadingCatalog) && ( )}
- {learnStore.selectedItems && learnStore.selectedItems.length > 0 && ( + {selectedItems.length > 0 && (
- {learnStore.selectedItems?.map(item => + {selectedItems.map(item => learnStore.catalog ? ( - + ) : ( ) diff --git a/client/src/Features/PublishAssignment/PublishActionButtons.tsx b/client/src/Features/PublishAssignment/PublishActionButtons.tsx index 310b082f..5ab14be9 100644 --- a/client/src/Features/PublishAssignment/PublishActionButtons.tsx +++ b/client/src/Features/PublishAssignment/PublishActionButtons.tsx @@ -34,6 +34,23 @@ interface PublishButtonStyleProps extends EditButtonStyleProps {}; const PublishActionButtonsInner = ({ styles }: IStylesOnly): JSX.Element => { const assignmentStore = useStore('assignmentStore'); + const assignmentLinksStore = useStore('assignmentLinksStore'); + const learnStore = useStore('microsoftLearnStore'); + + const learnStoreCallsInProgress = + !learnStore.isLoadingCatalog && + (learnStore.serviceCallsInProgress || learnStore.clearCallInProgress || learnStore.clearCallsToMake) + ? 1 + : 0; + const isCallInProgress = + learnStoreCallsInProgress + assignmentLinksStore.serviceCallInProgress + assignmentStore.serviceCallInProgress > 0; + const hasError = learnStore.hasServiceError || assignmentLinksStore.hasServiceError || assignmentStore.hasServiceError; + + const mainPublishSubtext = 'You are about to Publish the assignment and make it visible to the students.\nAre you sure you want to proceed?' + + const warningText = isCallInProgress ? 'Some parts of the assignment are still updating. We recommend you to please wait for a few seconds. Publishing now may lead to some data loss.' : + hasError ? 'Some parts of the assignment were not updated properly, but you are about to publish and make the assignment visible to the students. Publishing now may lead to some data loss. Head to the Preview page to see the saved state of the assignment which will be visible to the students.' : null; + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); @@ -54,7 +71,7 @@ const PublishActionButtonsInner = ({ styles }: IStylesOnly()(publishButtonStyle, { isPublished: assignmentStore.assignment?.publishStatus === 'Published', theme: getTheme() - }) + }); return (
@@ -74,6 +91,7 @@ const PublishActionButtonsInner = ({ styles }: IStylesOnly @@ -93,10 +111,9 @@ const PublishActionButtonsInner = ({ styles }: IStylesOnly setIsPublishDialogOpen(false)} approveButtonText="Publish" isOpen={isPublishDialogOpen} - subText={ - 'You are about to Publish the assignment and make it visible to the students.\nAre you sure you want to proceed?' - } + subText={mainPublishSubtext} title="Publish" + warningText={warningText} />
diff --git a/client/src/Features/PublishAssignment/PublishStatusDialog.tsx b/client/src/Features/PublishAssignment/PublishStatusDialog.tsx index 341bc6bc..226da421 100644 --- a/client/src/Features/PublishAssignment/PublishStatusDialog.tsx +++ b/client/src/Features/PublishAssignment/PublishStatusDialog.tsx @@ -12,11 +12,13 @@ import { DefaultButton, IModalProps, IModalStyles, - IDialogStyles + IDialogStyles, + Icon } from '@fluentui/react'; import { DIALOG_MIN_WIDTH, commonDialogContentProps } from '../../Core/Components/Common/Dialog/EdnaDialogStyles'; import { useStore } from '../../Stores/Core'; import { useObserver } from 'mobx-react-lite'; +import { FontSizes } from '@uifabric/fluent-theme'; interface PublishStatusDialogProps { onApprove: () => void; @@ -25,6 +27,7 @@ interface PublishStatusDialogProps { title: string; subText: string; approveButtonText: string; + warningText: string | null; } export const PublishStatusDialog = ({ @@ -34,6 +37,7 @@ export const PublishStatusDialog = ({ title, subText, approveButtonText, + warningText, styles }: PublishStatusDialogProps & IStylesOnly>): JSX.Element => { const [isDialogWindowVisible, setIsDialogWindowVisible] = useState(true); @@ -65,6 +69,13 @@ export const PublishStatusDialog = ({ minWidth={DIALOG_MIN_WIDTH} subText={subText} > + + {warningText && +
+ + {warningText} +
+ } @@ -77,7 +88,24 @@ export const PublishStatusDialog = ({ const modalStyle = (isDialogWindowVisible: boolean): Partial => ({ main: [ { - display: isDialogWindowVisible ? 'flex' : 'none' + display: isDialogWindowVisible ? 'flex' : 'none', + selectors: { + '.warning':{ + display: 'flex', + flexDirection: 'row', + selectors: { + '.warningIcon': { + fontSize: FontSizes.size32, + color: '#FFC100', + margin: 'auto' + }, + '.warningText': { + fontSize: FontSizes.size12, + paddingLeft: '8px' + } + } + } + } } ] }); diff --git a/client/src/Features/PublishAssignment/PublishSuccessMessageBar.tsx b/client/src/Features/PublishAssignment/PublishSuccessMessageBar.tsx index 4e3a98e7..f264e4ab 100644 --- a/client/src/Features/PublishAssignment/PublishSuccessMessageBar.tsx +++ b/client/src/Features/PublishAssignment/PublishSuccessMessageBar.tsx @@ -15,8 +15,10 @@ import { } from '@fluentui/react'; import { themedClassNames } from '../../Core/Utils/FluentUI'; -interface PublishSuccessMessageBarProps { - isPublished: boolean; +export interface PublishSuccessMessageBarProps { + showMessage: boolean; + messageBarType: MessageBarType; + message: string; } type PublishSuccessMessageBarStyles = Partial; @@ -24,7 +26,9 @@ const AUTO_HIDE_DURATION = 5000; const PublishSuccessMessageBarInner = ({ styles, - isPublished + showMessage, + messageBarType, + message }: PublishSuccessMessageBarProps & IStylesOnly): JSX.Element | null => { const [isShown, setIsShown] = useState(false); const isFirstRun = useRef(true); @@ -40,13 +44,13 @@ const PublishSuccessMessageBarInner = ({ ); useEffect(() => { - if (isPublished && isFirstRun && !isFirstRun.current) { + if (showMessage && isFirstRun && !isFirstRun.current) { setIsShown(true); timer.current = setTimeout(() => { setIsShown(false); }, AUTO_HIDE_DURATION); } - }, [isPublished]); + }, [showMessage]); useEffect(() => { if (isFirstRun.current) { @@ -57,8 +61,8 @@ const PublishSuccessMessageBarInner = ({ if (isShown) { return ( - - Your assignment was published successfully + + {message} ); } diff --git a/client/src/Features/StudentView/StudentViewContent.tsx b/client/src/Features/StudentView/StudentViewContent.tsx index bd646665..482eaacf 100644 --- a/client/src/Features/StudentView/StudentViewContent.tsx +++ b/client/src/Features/StudentView/StudentViewContent.tsx @@ -34,22 +34,23 @@ const StudentViewContentInner = ({ styles, requirePublished }: StudentViewConten const learnStore = useStore('microsoftLearnStore'); return useObserver(() => { + const syncedContentsToDisplay = [...learnStore.contentSelectionMap].filter(value => value[1].callStatus!=='in-progress' && value[1].syncedState==='selected').map(item => item[0]); const items: (StudentViewSectionProps & IStylesOnly)[] = _.compact([ - assignmentStore.assignment?.description && { + assignmentStore.syncedDescription && { title: 'Description', - textContent: assignmentStore.assignment.description + textContent: assignmentStore.syncedDescription }, - assignmentStore.assignment?.deadline && { + assignmentStore.syncedDeadline && { title: 'Deadline', - textContent: formatDate(assignmentStore.assignment.deadline) + textContent: formatDate(assignmentStore.syncedDeadline) }, - (assignmentLinksStore.isLoading || assignmentLinksStore.assignmentLinks.length > 0) && { + (assignmentLinksStore.isLoading || assignmentLinksStore.syncedAssignmentLinks.length > 0) && { title: 'Links', styles: linksSectionStyles, - content: + content: }, - learnStore.selectedItems && - learnStore.selectedItems.length > 0 && { + syncedContentsToDisplay && + syncedContentsToDisplay.length > 0 && { title: 'Tutorials', content: } diff --git a/client/src/Features/StudentView/StudentViewLearnItemsList.tsx b/client/src/Features/StudentView/StudentViewLearnItemsList.tsx index d7bd7ead..ebf15012 100644 --- a/client/src/Features/StudentView/StudentViewLearnItemsList.tsx +++ b/client/src/Features/StudentView/StudentViewLearnItemsList.tsx @@ -25,15 +25,19 @@ const StudentViewLearnItemsListInner = ({ styles }: IStylesOnly ( + return useObserver(() => { + + const syncedContentsToDisplay = [...learnStore.contentSelectionMap].filter(value => value[1].callStatus!=='in-progress' && value[1].syncedState==='selected').map(item => item[0]); + + return (
- {learnStore.selectedItems && - learnStore.selectedItems.map(selectedItem => { - const contentItem = learnStore.catalog?.contents.get(selectedItem.contentUid); + { syncedContentsToDisplay.length>0 && + syncedContentsToDisplay.map(selectedItem => { + const contentItem = learnStore.catalog?.contents.get(selectedItem); return contentItem ? ( - + ) : !learnStore.catalog ? (
@@ -42,7 +46,7 @@ const StudentViewLearnItemsListInner = ({ styles }: IStylesOnly - )); + )}); }; const studentViewLearnItemsListStyles = ({ theme }: IThemeOnlyProps): StudentViewLearnItemsListStyles => { diff --git a/client/src/Services/Assignment.service.ts b/client/src/Services/Assignment.service.ts index a78085dd..1b881d96 100644 --- a/client/src/Services/Assignment.service.ts +++ b/client/src/Services/Assignment.service.ts @@ -5,7 +5,8 @@ import { AssignmentDto } from '../Dtos/Assignment.dto'; import axios from 'axios'; -import { safeData, WithError } from '../Core/Utils/Axios/safeData'; +import { getServiceError, safeData, WithError } from '../Core/Utils/Axios/safeData'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; class AssignmentServiceClass { public async getAssignment(assignmentId: string): Promise> { @@ -16,20 +17,19 @@ class AssignmentServiceClass { return safeData(response); } - public async updateAssignment(assignment: AssignmentDto): Promise { - axios.post(`${process.env.REACT_APP_EDNA_ASSIGNMENT_SERVICE_URL}/assignments`, assignment).catch(function (_error) { - //TODO: YS handle errors - }); + public async updateAssignment(assignment: AssignmentDto): Promise { + const response = await axios.post(`${process.env.REACT_APP_EDNA_ASSIGNMENT_SERVICE_URL}/assignments`, assignment); + + return getServiceError(response); } - public async changeAssignmentPublishStatus(assignmentId: string, newPublishStatus: boolean): Promise { + public async changeAssignmentPublishStatus(assignmentId: string, newPublishStatus: boolean): Promise { const serviceAction = newPublishStatus ? 'publish' : 'unpublish'; - await axios.post( + const response = await axios.post( `${process.env.REACT_APP_EDNA_ASSIGNMENT_SERVICE_URL}/assignments/${assignmentId}/${serviceAction}` ); - return true; - // TODO: Handle errors + return getServiceError(response); } } diff --git a/client/src/Services/AssignmentLinks.service.ts b/client/src/Services/AssignmentLinks.service.ts index 22388743..1e2169fc 100644 --- a/client/src/Services/AssignmentLinks.service.ts +++ b/client/src/Services/AssignmentLinks.service.ts @@ -5,7 +5,8 @@ import { AssignmentLinkDto } from '../Dtos/AssignmentLink.dto'; import axios from 'axios'; -import { safeData, WithError } from '../Core/Utils/Axios/safeData'; +import { getServiceError, safeData, WithError } from '../Core/Utils/Axios/safeData'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; class AssignmentLinksServiceClass { public async getAssignmentLinks(assignmentId: string): Promise> { @@ -16,20 +17,19 @@ class AssignmentLinksServiceClass { return safeData(response); } - public async updateAssignmentLink(assignmentLink: AssignmentLinkDto, assignmentId: string): Promise { - await axios.post( - `${process.env.REACT_APP_EDNA_LINKS_SERVICE_URL}/assignments/${assignmentId}/links/${assignmentLink.id}`, + public async updateAssignmentLink(assignmentLink: AssignmentLinkDto, assignmentId: string): Promise { + const response = await axios.post( + `${process.env.REACT_APP_EDNA_LINKS_SERVICE_URL}/assignments/${assignmentId}/links/linkid`, assignmentLink ); - //TODO: YS handle errors + return getServiceError(response); } - public async deleteAssignmentLink(assignmentLinkId: string, assignmentId: string): Promise { - await axios.delete( - `${process.env.REACT_APP_EDNA_LINKS_SERVICE_URL}/assignments/${assignmentId}/links/${assignmentLinkId}` + public async deleteAssignmentLink(assignmentLinkId: string, assignmentId: string): Promise { + const response = await axios.delete( + `${process.env.REACT_APP_EDNA_LINKS_SERVICE_URL}/assignments/${assignmentId}/links/linkid` ); - - //TODO: YS handle errors + return getServiceError(response); } } diff --git a/client/src/Services/MicrosoftLearn.service.ts b/client/src/Services/MicrosoftLearn.service.ts index 3d8b5685..f7147ff5 100644 --- a/client/src/Services/MicrosoftLearn.service.ts +++ b/client/src/Services/MicrosoftLearn.service.ts @@ -5,8 +5,9 @@ import { CatalogDto } from '../Dtos/Learn/Catalog.dto'; import axios from 'axios'; -import { safeData, WithError } from '../Core/Utils/Axios/safeData'; +import { safeData, WithError, getServiceError } from '../Core/Utils/Axios/safeData'; import { AssignmentLearnContentDto } from '../Dtos/Learn/AssignmentLearnContent.dto'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; class MicrosoftLearnServiceClass { public async getCatalog(): Promise> { @@ -18,24 +19,28 @@ class MicrosoftLearnServiceClass { const assignmentLearnContentResponse = await axios.get( `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content` ); - return safeData(assignmentLearnContentResponse); } - public async saveAssignmentLearnContent(assignmentId: string, learnContentUid: string): Promise { - await axios.post( - `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content/${learnContentUid}` + public async saveAssignmentLearnContent(assignmentId: string, learnContentUid: string): Promise { + const assignmentLearnServiceresponse = await axios.post( + `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content/contentuid` ); + return getServiceError(assignmentLearnServiceresponse); } - public async removeAssignmentLearnContent(assignmentId: string, learnContentUid: string): Promise { - await axios.delete( - `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content/${learnContentUid}` + public async removeAssignmentLearnContent(assignmentId: string, learnContentUid: string): Promise { + const assignmentLearnServiceresponse = await axios.delete( + `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content/contentuid` ); + return getServiceError(assignmentLearnServiceresponse); } - public async clearAssignmentLearnContent(assignmentId: string): Promise { - await axios.delete(`${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content`); + public async clearAssignmentLearnContent(assignmentId: string): Promise { + const assignmentLearnServiceresponse = await axios.delete( + `${process.env.REACT_APP_EDNA_LEARN_CONTENT}/assignments/${assignmentId}/learn-content` + ); + return getServiceError(assignmentLearnServiceresponse); } } diff --git a/client/src/Stores/Assignment.store.ts b/client/src/Stores/Assignment.store.ts index 0585601a..2498cef3 100644 --- a/client/src/Stores/Assignment.store.ts +++ b/client/src/Stores/Assignment.store.ts @@ -3,17 +3,58 @@ * Licensed under the MIT License. *--------------------------------------------------------------------------------------------*/ -import { observable, action } from 'mobx'; +import _ from 'lodash'; +import { observable, action, computed } from 'mobx'; import { ChildStore } from './Core'; import { AssignmentService } from '../Services/Assignment.service'; import { Assignment } from '../Models/Assignment.model'; import { ErrorPageContent } from '../Core/Components/ErrorPageContent'; +import { toObservable } from '../Core/Utils/Mobx/toObservable'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; export class AssignmentStore extends ChildStore { @observable assignment: Assignment | null = null; @observable isChangingPublishState: boolean | null = null; @observable errorContent : ErrorPageContent | undefined = undefined; + @observable pushlishStatusChangeError: ServiceError | null = null; + @observable syncedDescription: string | null = null; + @observable syncedDeadline: Date | null = null; + @observable serviceCallInProgress: number = 0; + @observable hasServiceError: ServiceError | null = null; + @computed get isSynced(): boolean{ + return _.isEqual(this.syncedDeadline, this.assignment?.deadline) && _.isEqual(this.syncedDescription, this.assignment?.description); + } + + @computed get isAssignmentPublished(): boolean { + return this.assignment?.publishStatus === 'Published'; + } + + initialize(): void { + + const updateAssignmentFromSyncedState = () => { + if(this.assignment) + { + this.assignment = { ...this.assignment, deadline: this.syncedDeadline, description: this.syncedDescription!! }; + } + } + + toObservable(() => this.isAssignmentPublished) + .subscribe(isAssignmentPublished => { + if(isAssignmentPublished === true){ + updateAssignmentFromSyncedState(); + this.hasServiceError = null; + } + }) + + toObservable(() => this.serviceCallInProgress) + .subscribe(serviceCallInProgress => { + if(serviceCallInProgress === 0 && this.assignment?.publishStatus === 'Published' ){ + updateAssignmentFromSyncedState(); + } + }) + } + @action async initializeAssignment(assignmentId: string): Promise { const assignment = await AssignmentService.getAssignment(assignmentId); @@ -23,13 +64,24 @@ export class AssignmentStore extends ChildStore { } const { deadline } = assignment; this.assignment = deadline ? { ...assignment, deadline: new Date(deadline) } : assignment; + this.syncedDescription = this.assignment.description; + this.syncedDeadline = this.assignment.deadline; } @action updateAssignmentDeadline(newDeadline: Date): void { if (this.assignment) { this.assignment.deadline = newDeadline; - AssignmentService.updateAssignment(this.assignment); + this.serviceCallInProgress++; + AssignmentService.updateAssignment(this.assignment) + .then(hasErrors => { + if(hasErrors === null) { + this.syncedDeadline = newDeadline; + }else { + this.hasServiceError = hasErrors; + } + this.serviceCallInProgress--; + }) } } @@ -37,7 +89,17 @@ export class AssignmentStore extends ChildStore { updateAssignmentDescription(newDescription: string): void { if (this.assignment) { this.assignment.description = newDescription; - AssignmentService.updateAssignment(this.assignment); + this.serviceCallInProgress++; + AssignmentService.updateAssignment(this.assignment) + .then(hasErrors => { + if(hasErrors === null) { + this.syncedDescription = newDescription; + }else { + this.hasServiceError = hasErrors; + } + this.serviceCallInProgress--; + }) + } } @@ -45,12 +107,16 @@ export class AssignmentStore extends ChildStore { async changeAssignmentPublishStatus(newPublishStatus: boolean): Promise { if (this.assignment) { this.isChangingPublishState = true; - const isStatusChanged = await AssignmentService.changeAssignmentPublishStatus( + this.pushlishStatusChangeError = null; + + const hasError = await AssignmentService.changeAssignmentPublishStatus( this.assignment.id, newPublishStatus ); - if (isStatusChanged) { + if (hasError === null) { this.assignment.publishStatus = newPublishStatus ? 'Published' : 'NotPublished'; + } else { + this.pushlishStatusChangeError = hasError; } this.isChangingPublishState = false; } diff --git a/client/src/Stores/AssignmentLinks.store.ts b/client/src/Stores/AssignmentLinks.store.ts index ef78d8da..6da00772 100644 --- a/client/src/Stores/AssignmentLinks.store.ts +++ b/client/src/Stores/AssignmentLinks.store.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. *--------------------------------------------------------------------------------------------*/ -import { observable, action } from 'mobx'; +import { observable, action, computed } from 'mobx'; import _ from 'lodash'; import { ChildStore } from './Core'; import { AssignmentLink } from '../Models/AssignmentLink.model'; @@ -11,54 +11,119 @@ import { AssignmentLinksService } from '../Services/AssignmentLinks.service'; import { toObservable } from '../Core/Utils/Mobx/toObservable'; import { switchMap, filter, map } from 'rxjs/operators'; import { AssignmentLinkDto } from '../Dtos/AssignmentLink.dto'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; +import { WithError } from '../Core/Utils/Axios/safeData'; export class AssignmentLinksStore extends ChildStore { @observable assignmentLinks: AssignmentLink[] = []; @observable isLoading = true; + @observable syncedAssignmentLinks: AssignmentLink[] = []; + @observable serviceCallInProgress: number = 0; + @observable hasServiceError: ServiceError | null = null; + @observable addOrUpdateCallInProgress: string[] = []; + + @computed get isSynced(): boolean { + return _.isEqual(this.assignmentLinks,this.syncedAssignmentLinks); + } initialize(): void { toObservable(() => this.root.assignmentStore.assignment) .pipe(filter(assignment => !assignment)) .subscribe(() => (this.assignmentLinks = [])); - toObservable(() => this.root.assignmentStore.assignment) + const getLinks = async (assignmentId : string) : Promise> => + { + const links = await AssignmentLinksService.getAssignmentLinks(assignmentId); + if(links.error) { + this.hasServiceError = links.error; + } + return links; + } + + toObservable(() => this.root.assignmentStore.assignment?.id) .pipe( - filter(assignment => !!assignment), - map(assignment => assignment!.id), - switchMap(AssignmentLinksService.getAssignmentLinks), + filter(assignmentId => assignmentId !== undefined), + map(assignmentId => assignmentId!!), + switchMap(getLinks), filter(links => !links.error), map(links => links as AssignmentLinkDto[]) ) .subscribe((links: AssignmentLinkDto[]) => { this.assignmentLinks = links; + this.syncedAssignmentLinks = links; this.isLoading = false; }); + + toObservable(() => this.root.assignmentStore.assignment?.publishStatus) + .subscribe(publishStatus => { + if(publishStatus === 'Published'){ + this.assignmentLinks = this.syncedAssignmentLinks; + this.hasServiceError = null; + } + }) + + toObservable(() => this.serviceCallInProgress) + .subscribe(serviceCallInProgress => { + if(serviceCallInProgress === 0 && this.root.assignmentStore.assignment?.publishStatus === 'Published'){ + this.assignmentLinks = this.syncedAssignmentLinks; + } + }) } @action addAssignmentLink(assignmentLink: AssignmentLink): void { + this.serviceCallInProgress++; this.assignmentLinks = [...this.assignmentLinks, assignmentLink]; const assignmentId = this.root.assignmentStore.assignment!.id; + this.addOrUpdateCallInProgress.push(assignmentLink.id) - AssignmentLinksService.updateAssignmentLink(assignmentLink, assignmentId); + AssignmentLinksService.updateAssignmentLink(assignmentLink, assignmentId) + .then(hasErrors => { + if(hasErrors === null) { + this.syncedAssignmentLinks = [...this.syncedAssignmentLinks, assignmentLink]; + } else { + this.hasServiceError = hasErrors; + } + this.addOrUpdateCallInProgress = this.addOrUpdateCallInProgress.filter(linkId => linkId !== assignmentLink.id) + this.serviceCallInProgress--; + }) } @action editAssignmentLink(editedLink: AssignmentLink): void { + this.serviceCallInProgress++; const updatedLinks = this.assignmentLinks.map(link => (link.id === editedLink.id ? editedLink : link)); - const assignmentId = this.root.assignmentStore.assignment!.id; - this.assignmentLinks = updatedLinks; - AssignmentLinksService.updateAssignmentLink(editedLink, assignmentId); + this.addOrUpdateCallInProgress.push(editedLink.id); + + AssignmentLinksService.updateAssignmentLink(editedLink, assignmentId) + .then(hasErrors => { + if(hasErrors === null) { + this.syncedAssignmentLinks = this.syncedAssignmentLinks.map(link => (link.id === editedLink.id ? editedLink : link)); + } else { + this.hasServiceError = hasErrors; + } + this.addOrUpdateCallInProgress = this.addOrUpdateCallInProgress.filter(linkId => linkId !== editedLink.id); + this.serviceCallInProgress--; + }) } @action deleteAssignmentLink(assignmentLinkId: string): void { + this.serviceCallInProgress++; const updatedLinks = _.filter(this.assignmentLinks, link => link.id !== assignmentLinkId); const assignmentId = this.root.assignmentStore.assignment!.id; - this.assignmentLinks = updatedLinks; - AssignmentLinksService.deleteAssignmentLink(assignmentLinkId, assignmentId); + + AssignmentLinksService.deleteAssignmentLink(assignmentLinkId, assignmentId) + .then(hasErrors => { + if(hasErrors === null) { + this.syncedAssignmentLinks = _.filter(this.syncedAssignmentLinks, link => link.id !== assignmentLinkId); + } else { + this.hasServiceError = hasErrors; + } + this.serviceCallInProgress--; + }) } } diff --git a/client/src/Stores/MicrosoftLearn.store.ts b/client/src/Stores/MicrosoftLearn.store.ts index cde8b7b0..248d15e9 100644 --- a/client/src/Stores/MicrosoftLearn.store.ts +++ b/client/src/Stores/MicrosoftLearn.store.ts @@ -4,23 +4,59 @@ *--------------------------------------------------------------------------------------------*/ import { ChildStore } from './Core'; -import { observable, action } from 'mobx'; +import { observable, action, ObservableMap, computed } from 'mobx'; import { Catalog, LearnContent, Product } from '../Models/Learn'; import { MicrosoftLearnService } from '../Services/MicrosoftLearn.service'; import { CatalogDto, ProductChildDto, ProductDto } from '../Dtos/Learn'; import { toMap } from '../Core/Utils/Typescript/ToMap'; import { toObservable } from '../Core/Utils/Mobx/toObservable'; -import { AssignmentLearnContent } from '../Models/Learn/AssignmentLearnContent'; import { AssignmentLearnContentDto } from '../Dtos/Learn/AssignmentLearnContent.dto'; import { MicrosoftLearnFilterStore } from './MicrosoftLearnFilter.store'; -import { debounceTime, map, filter, switchMap } from 'rxjs/operators'; +import { debounceTime, map, filter, switchMap, tap } from 'rxjs/operators'; import { applySelectedFilter, getFiltersToDisplay } from '../Features/MicrosoftLearn/MicrosoftLearnFilterCore'; +import { ServiceError } from '../Core/Utils/Axios/ServiceError'; +import { WithError } from '../Core/Utils/Axios/safeData'; +import _ from 'lodash'; + +enum LearnContentState { + selected = 'selected', + notSelected = 'not-selected' +} + +enum CallStatus { + success = 'success', + inProgress = 'in-progress', + error = 'error' +} + +export type ContentSelectionProps = { + userState: LearnContentState; + syncedState: LearnContentState; + callStatus: CallStatus; +} export class MicrosoftLearnStore extends ChildStore { @observable isLoadingCatalog: boolean | null = null; @observable catalog: Catalog | null = null; - @observable selectedItems: AssignmentLearnContent[] | null = null; @observable filteredCatalogContent: LearnContent[] | null = null; + @observable contentSelectionMap: ObservableMap = observable.map(); + @observable clearCallInProgress: boolean = false; + @observable clearCallsToMake: boolean = false; + @observable hasServiceError: ServiceError | null = null; + + @computed get serviceCallsInProgress(): boolean { + return _.sumBy([...this.contentSelectionMap.values()].map(item => item.callStatus === CallStatus.inProgress? 1 : 0)) !== 0; + } + + @computed get unSyncedItems(): Array<[string, ContentSelectionProps]> { + return [...this.contentSelectionMap].filter( + item => this.clearCallInProgress === false && this.clearCallsToMake === false && this.isItemUnsynced(item) + ); + } + + @computed get itemsInErrorState(): Array<[string, ContentSelectionProps]>{ + return [...this.contentSelectionMap].filter(([contentUid, contentProps]) => contentProps.callStatus===CallStatus.error); + } filterStore = new MicrosoftLearnFilterStore(); @@ -41,49 +77,199 @@ export class MicrosoftLearnStore extends ChildStore { this.filterStore.displayFilter = filtersToDisplay; }); - toObservable(() => this.root.assignmentStore.assignment) + const getLearnContent = async (assignmentId: string): Promise> => { + const assignmentLearnContent = await MicrosoftLearnService.getAssignmentLearnContent(assignmentId); + if (assignmentLearnContent.error) { + this.hasServiceError = assignmentLearnContent.error; + } + return assignmentLearnContent; + }; + + toObservable(() => this.root.assignmentStore.assignment?.id) .pipe( - filter(assignment => !!assignment), - map(assignment => assignment!.id), - switchMap(assignmentId => MicrosoftLearnService.getAssignmentLearnContent(assignmentId)), + filter(assignmentId => assignmentId !== undefined), + map(assignmentId => assignmentId!!), + switchMap(getLearnContent), filter(assignmentLearnContent => !assignmentLearnContent.error), map(assignmentLearnContent => assignmentLearnContent as AssignmentLearnContentDto[]) ) .subscribe(selectedItems => { - this.selectedItems = selectedItems; + // TODO: remove this check after testing! + selectedItems.forEach(item => + {item.contentUid!=='contentuid' && this.contentSelectionMap.set(item.contentUid, { + userState: LearnContentState.selected, + syncedState: LearnContentState.selected, + callStatus: CallStatus.success + })} + ); }); + + const updateUserState = () => { + [...this.contentSelectionMap] + .filter(([contentUid, contentProps]) => contentProps.syncedState !== contentProps.userState) + .forEach(([contentUid, contentProps]) => + this.contentSelectionMap.set(contentUid, { ...contentProps, userState: contentProps.syncedState, callStatus: CallStatus.success }) + ); + }; + toObservable(() => this.root.assignmentStore.assignment?.publishStatus).subscribe(publishStatus => { + if (publishStatus === 'Published' && this.serviceCallsInProgress === false) { + updateUserState(); + this.hasServiceError = null; + } + }); + toObservable(() => this.serviceCallsInProgress).subscribe(serviceCallsInProgress => { + if ( + serviceCallsInProgress === false && + this.root.assignmentStore.assignment?.publishStatus === 'Published' + ) { + updateUserState(); + } + }); + + toObservable(() => this.unSyncedItems) + .pipe( + debounceTime(500), + filter(unSyncedItems => unSyncedItems.length > 0), + tap(unSyncedItems => + unSyncedItems.forEach(([contentUid, contentProps]) => + this.contentSelectionMap.set(contentUid, { ...contentProps, callStatus: CallStatus.inProgress }) + ) + ), + map(unSyncedItems => + unSyncedItems.map(item => this.makeToggleServiceCall(item, this.root.assignmentStore.assignment?.id!!)) + ) + ) + .subscribe(serviceCallPromises => { + serviceCallPromises.forEach(promise => { + this.handleToggleServiceCallResponse(promise, this.root.assignmentStore.assignment?.id!!); + }); + }); + + toObservable( + () => this.clearCallsToMake === true && this.clearCallInProgress === false && this.serviceCallsInProgress === false + ).subscribe(async makeClearCall => { + if (makeClearCall) { + this.clearCallInProgress = true; + this.clearCallsToMake = false; + const itemsToClear = [...this.contentSelectionMap].filter( + ([contentUid, contentProps]) => + (contentProps.syncedState === LearnContentState.selected) || + (contentProps.syncedState === LearnContentState.notSelected && contentProps.callStatus===CallStatus.error)); + let promise = MicrosoftLearnService.clearAssignmentLearnContent(this.root.assignmentStore.assignment!.id); + this.handelClearCallResponse(promise, itemsToClear); + } + }); } - @action - removeItemSelection(learnContentUid: string): void { - const assignmentId = this.root.assignmentStore.assignment!.id; - const itemIndexInSelectedItemsList = this.getItemIndexInSelectedList(learnContentUid); - if (itemIndexInSelectedItemsList == null) { - return; + isItemUnsynced = ([contentUid, contentProps]: [string, ContentSelectionProps]) => + contentProps.syncedState !== contentProps.userState && + contentProps.callStatus === CallStatus.success; + + async handelClearCallResponse( + promise: Promise, + itemsToClear: [string, ContentSelectionProps][] + ) { + let serviceError = await promise; + if (serviceError === null) { + itemsToClear.forEach(([contentUid, contentProps]) => { + let previousState = this.contentSelectionMap.get(contentUid)!!; + this.contentSelectionMap.set(contentUid, { ...previousState, callStatus: CallStatus.success, syncedState: LearnContentState.notSelected }); + }); + } else { + this.hasServiceError = serviceError; + itemsToClear.forEach(([contentUid, contentProps]) => { + let previousState = this.contentSelectionMap.get(contentUid)!!; + this.contentSelectionMap.set(contentUid, { ...previousState, callStatus: CallStatus.error }); + }); + } + this.clearCallInProgress = false; + } + + async makeToggleServiceCall([contentUid, contentProps]: [string, ContentSelectionProps], assignmentId: string) { + switch (contentProps.userState) { + case LearnContentState.selected: + return { + contentUid, + contentProps, + error: await MicrosoftLearnService.saveAssignmentLearnContent(assignmentId, contentUid) + }; + case LearnContentState.notSelected: + return { + contentUid, + contentProps, + error: await MicrosoftLearnService.removeAssignmentLearnContent(assignmentId, contentUid) + }; + } + } + + async handleToggleServiceCallResponse( + promise: Promise<{ contentUid: string; contentProps: ContentSelectionProps; error: ServiceError | null }>, + assignmentId: string + ) { + let response = await promise; + if (response.error !== null) { + this.hasServiceError = response.error; + this.contentSelectionMap.set(response.contentUid, { + ...this.contentSelectionMap.get(response.contentUid)!!, + callStatus: CallStatus.error + }); + } else { + let currentContentState = this.contentSelectionMap.get(response.contentUid)!!; + this.contentSelectionMap.set(response.contentUid, { + ...currentContentState, + syncedState: response.contentProps.userState + }); + + if (currentContentState.userState !== response.contentProps.userState && !this.clearCallsToMake) { + let promise = this.makeToggleServiceCall( + [response.contentUid, this.contentSelectionMap.get(response.contentUid)!!], + assignmentId + ); + this.handleToggleServiceCallResponse(promise, assignmentId); + } else { + this.contentSelectionMap.set(response.contentUid, { + ...this.contentSelectionMap.get(response.contentUid)!!, + callStatus: CallStatus.success + }); + } } - this.applyRemoveItemSelection(itemIndexInSelectedItemsList, assignmentId, learnContentUid); } @action toggleItemSelection(learnContentUid: string): void { - const assignmentId = this.root.assignmentStore.assignment!.id; - const itemIndexInSelectedItemsList = this.getItemIndexInSelectedList(learnContentUid); - if (itemIndexInSelectedItemsList == null) { - return; - } - if (itemIndexInSelectedItemsList > -1) { - this.applyRemoveItemSelection(itemIndexInSelectedItemsList, assignmentId, learnContentUid); + let previousState = this.contentSelectionMap.get(learnContentUid); + if (previousState && previousState.userState === LearnContentState.selected) { + this.contentSelectionMap.set(learnContentUid, { + ...previousState, + callStatus: previousState?.callStatus === CallStatus.error ? CallStatus.success : previousState?.callStatus, + userState: LearnContentState.notSelected + }); + } else if (previousState) { + this.contentSelectionMap.set(learnContentUid, { + ...previousState, + callStatus: previousState?.callStatus === CallStatus.error ? CallStatus.success : previousState?.callStatus, + userState: LearnContentState.selected + }); } else { - this.selectedItems?.push({ contentUid: learnContentUid }); - MicrosoftLearnService.saveAssignmentLearnContent(assignmentId, learnContentUid); + this.contentSelectionMap.set(learnContentUid, { + userState: LearnContentState.selected, + syncedState: LearnContentState.notSelected, + callStatus: CallStatus.success + }); } } @action clearAssignmentLearnContent(): void { - this.selectedItems = []; - const assignmentId = this.root.assignmentStore.assignment!.id; - MicrosoftLearnService.clearAssignmentLearnContent(assignmentId); + debounceTime(500); + this.clearCallsToMake = true; + const itemsToClear = [...this.contentSelectionMap].filter(item => item[1].userState === LearnContentState.selected); + itemsToClear.forEach(item => + this.contentSelectionMap.set(item[0], { + ...item[1], + userState: LearnContentState.notSelected + }) + ); } @action @@ -104,23 +290,9 @@ export class MicrosoftLearnStore extends ChildStore { this.catalog = { contents: items, products, roles, levels }; this.isLoadingCatalog = false; this.filteredCatalogContent = allItems; - this.filterStore.initializeFilters(this.catalog, searchParams); } - private getItemIndexInSelectedList = (learnContentUid: string): number | void => { - return this.selectedItems?.findIndex(item => item.contentUid === learnContentUid); - }; - - private applyRemoveItemSelection = ( - itemIndexInSelectedItemsList: number, - assignmentId: string, - learnContentUid: string - ): void => { - this.selectedItems?.splice(itemIndexInSelectedItemsList, 1); - MicrosoftLearnService.removeAssignmentLearnContent(assignmentId, learnContentUid); - }; - private getProducts = (catalog: CatalogDto): Map => { const productsMap = new Map(); const setItemInCatalog = (item: ProductChildDto | ProductDto, parent?: ProductDto): void => { diff --git a/client/src/Stores/Users.store.ts b/client/src/Stores/Users.store.ts index 19be020a..328a0400 100644 --- a/client/src/Stores/Users.store.ts +++ b/client/src/Stores/Users.store.ts @@ -53,9 +53,9 @@ export class UsersStore extends ChildStore { } return user; } - const detailsFromAssignment = toObservable(() => this.root.assignmentStore.assignment).pipe( - filter(assignment => !!assignment), - map(assignment => assignment!.id), + const detailsFromAssignment = toObservable(() => this.root.assignmentStore.assignment?.id).pipe( + filter(assignmentId => assignmentId !== undefined), + map(assignmentId => assignmentId!!), switchMap(getUser), filter(user => !user.error), map(user => user as UserDto),