diff --git a/assets/src/apps/authoring/components/AdaptiveRulesList/AdaptiveRulesList.tsx b/assets/src/apps/authoring/components/AdaptiveRulesList/AdaptiveRulesList.tsx index 7b8f3886a7a..e7ed48b4cbd 100644 --- a/assets/src/apps/authoring/components/AdaptiveRulesList/AdaptiveRulesList.tsx +++ b/assets/src/apps/authoring/components/AdaptiveRulesList/AdaptiveRulesList.tsx @@ -11,6 +11,7 @@ import { selectCopiedItem, selectCopiedType, } from 'apps/authoring/store/clipboard/slice'; +import { setCurrentPartPropertyFocus } from 'apps/authoring/store/parts/slice'; import guid from 'utils/guid'; import { useToggle } from '../../../../components/hooks/useToggle'; import { clone } from '../../../../utils/common'; @@ -467,8 +468,14 @@ const IRulesList: React.FC = (props: any) => { value={ruleToEdit.name} onClick={(e) => e.preventDefault()} onChange={(e) => setRuleToEdit({ ...rule, name: e.target.value })} - onFocus={(e) => e.target.select()} - onBlur={() => handleRenameRule(rule)} + onFocus={(e) => { + e.target.select(); + dispatch(setCurrentPartPropertyFocus({ focus: false })); + }} + onBlur={() => { + handleRenameRule(rule); + dispatch(setCurrentPartPropertyFocus({ focus: true })); + }} onKeyDown={(e) => { if (e.key === 'Enter') handleRenameRule(rule); if (e.key === 'Escape') setRuleToEdit(undefined); diff --git a/assets/src/apps/authoring/components/AdaptivityEditor/ConditionItemEditor.tsx b/assets/src/apps/authoring/components/AdaptivityEditor/ConditionItemEditor.tsx index 608f22078a9..4a0ca9c394f 100644 --- a/assets/src/apps/authoring/components/AdaptivityEditor/ConditionItemEditor.tsx +++ b/assets/src/apps/authoring/components/AdaptivityEditor/ConditionItemEditor.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { useDispatch } from 'react-redux'; +import { setCurrentPartPropertyFocus } from 'apps/authoring/store/parts/slice'; import { CapiVariableTypes, JanusConditionProperties } from '../../../../adaptivity/capi'; import ConfirmDelete from '../Modal/DeleteConfirmationModal'; import { @@ -22,7 +24,7 @@ interface ConditionItemEditorProps { const ConditionItemEditor: React.FC = (props) => { const { condition, parentIndex, onChange, onDelete } = props; - + const dispatch = useDispatch(); const [fact, setFact] = useState(condition.fact); const [targetType, setTargetType] = useState( condition.type || inferTypeFromOperatorAndValue(condition.operator, condition.value), @@ -120,7 +122,11 @@ const ConditionItemEditor: React.FC = (props) => { placeholder="Target" value={fact} onChange={(e) => setFact(e.target.value)} - onBlur={(e) => handleFactChange(e.target.value)} + onFocus={(e) => dispatch(setCurrentPartPropertyFocus({ focus: false }))} + onBlur={(e) => { + handleFactChange(e.target.value); + dispatch(setCurrentPartPropertyFocus({ focus: true })); + }} title={fact.toString()} tabIndex={0} /> @@ -171,7 +177,11 @@ const ConditionItemEditor: React.FC = (props) => { key={`value-${parentIndex}`} id={`value-${parentIndex}`} defaultValue={value} - onBlur={(e) => handleValueChange(e)} + onBlur={(e) => { + handleValueChange(e); + dispatch(setCurrentPartPropertyFocus({ focus: true })); + }} + onFocus={(e) => dispatch(setCurrentPartPropertyFocus({ focus: false }))} title={value.toString()} placeholder="Value" tabIndex={0} diff --git a/assets/src/apps/authoring/components/ComponentToolbar/AddComponentToolbar.tsx b/assets/src/apps/authoring/components/ComponentToolbar/AddComponentToolbar.tsx index 6ccae13d976..d1217bd1f8f 100644 --- a/assets/src/apps/authoring/components/ComponentToolbar/AddComponentToolbar.tsx +++ b/assets/src/apps/authoring/components/ComponentToolbar/AddComponentToolbar.tsx @@ -115,7 +115,16 @@ const AddComponentToolbar: React.FC<{ dispatch(setCopiedPart({ copiedPart: null })); }; - useKeyDown(handlePartPasteClick, ['KeyV'], { ctrlKey: true }, [copiedPart, currentActivityTree]); + useKeyDown( + () => { + if (copiedPart) { + handlePartPasteClick(); + } + }, + ['KeyV'], + { ctrlKey: true }, + [copiedPart, currentActivityTree], + ); return ( diff --git a/assets/src/apps/authoring/components/EditingCanvas/EditingCanvas.tsx b/assets/src/apps/authoring/components/EditingCanvas/EditingCanvas.tsx index 819a94c60d6..40f20e6e803 100644 --- a/assets/src/apps/authoring/components/EditingCanvas/EditingCanvas.tsx +++ b/assets/src/apps/authoring/components/EditingCanvas/EditingCanvas.tsx @@ -3,9 +3,15 @@ import { useDispatch, useSelector } from 'react-redux'; import { EntityId } from '@reduxjs/toolkit'; import { updatePart } from 'apps/authoring/store/parts/actions/updatePart'; import { NotificationType } from 'apps/delivery/components/NotificationContext'; +import { useKeyDown } from 'hooks/useKeyDown'; import { selectCurrentActivityTree } from '../../../delivery/store/features/groups/selectors/deck'; import { selectBottomPanel, setCopiedPart, setRightPanelActiveTab } from '../../store/app/slice'; -import { selectCurrentSelection, setCurrentSelection } from '../../store/parts/slice'; +import { + selectCurrentPartPropertyFocus, + selectCurrentSelection, + setCurrentPartPropertyFocus, + setCurrentSelection, +} from '../../store/parts/slice'; import { RightPanelTabs } from '../RightMenu/RightMenu'; import AuthoringActivityRenderer from './AuthoringActivityRenderer'; import ConfigurationModal from './ConfigurationModal'; @@ -16,7 +22,7 @@ const EditingCanvas: React.FC = () => { const _bottomPanelState = useSelector(selectBottomPanel); const currentActivityTree = useSelector(selectCurrentActivityTree); const _currentPartSelection = useSelector(selectCurrentSelection); - + const _currentPartPropertyFocus = useSelector(selectCurrentPartPropertyFocus); const [_currentActivity] = (currentActivityTree || []).slice(-1); const [currentActivityId, setCurrentActivityId] = useState(''); @@ -24,7 +30,7 @@ const EditingCanvas: React.FC = () => { const [showConfigModal, setShowConfigModal] = useState(false); const [configModalFullscreen, setConfigModalFullscreen] = useState(false); const [configPartId, setConfigPartId] = useState(''); - + const [currentSelectedPartId, setCurrentSelectedPartId] = useState(''); const [notificationStream, setNotificationStream] = useState<{ stamp: number; type: NotificationType; @@ -74,13 +80,13 @@ const EditingCanvas: React.FC = () => { const handlePartSelect = async (id: string) => { /* console.log('[handlePartSelect]', { id }); */ dispatch(setCurrentSelection({ selection: id })); - + setCurrentSelectedPartId(id); dispatch( setRightPanelActiveTab({ rightPanelActiveTab: !id.length ? RightPanelTabs.SCREEN : RightPanelTabs.COMPONENT, }), ); - + dispatch(setCurrentPartPropertyFocus({ focus: true })); return true; }; @@ -126,6 +132,39 @@ const EditingCanvas: React.FC = () => { dispatch(setRightPanelActiveTab({ rightPanelActiveTab: RightPanelTabs.SCREEN })); }, [currentActivityId]); + useKeyDown( + () => { + if (currentSelectedPartId && !configPartId?.length && _currentPartPropertyFocus) { + setNotificationStream({ + stamp: Date.now(), + type: NotificationType.CHECK_SHORTCUT_ACTIONS, + payload: { id: currentSelectedPartId, type: 'Delete' }, + }); + } + }, + ['Delete', 'Backspace'], + {}, + [currentSelectedPartId, configPartId, _currentPartPropertyFocus], + ); + + useKeyDown( + () => { + if (currentSelectedPartId && !configPartId?.length && _currentPartPropertyFocus) { + setNotificationStream({ + stamp: Date.now(), + type: NotificationType.CHECK_SHORTCUT_ACTIONS, + payload: { id: currentSelectedPartId, type: 'Copy' }, + }); + } else if (!_currentPartPropertyFocus) { + //if user first copies a part and then before pasting it, if they click on the properties and do a cntrl+c, we need to clear the existing cntrl+c for part + dispatch(setCopiedPart({ copiedPart: null })); + } + }, + ['KeyC'], + { ctrlKey: true }, + [currentSelectedPartId, _currentPartPropertyFocus], + ); + const configEditorId = `config-editor-${currentActivityId}`; return ( diff --git a/assets/src/apps/authoring/components/Flowchart/toolbar/FlowchartHeaderNav.tsx b/assets/src/apps/authoring/components/Flowchart/toolbar/FlowchartHeaderNav.tsx index 4ee9172175a..72346fc7466 100644 --- a/assets/src/apps/authoring/components/Flowchart/toolbar/FlowchartHeaderNav.tsx +++ b/assets/src/apps/authoring/components/Flowchart/toolbar/FlowchartHeaderNav.tsx @@ -207,7 +207,16 @@ export const FlowchartHeaderNav: React.FC = () => { ['KeyZ'], { ctrlKey: true }, ); - useKeyDown(handlePartPasteClick, ['KeyV'], { ctrlKey: true }, [copiedPart, currentActivityTree]); + useKeyDown( + () => { + if (copiedPart) { + handlePartPasteClick(); + } + }, + ['KeyV'], + { ctrlKey: true }, + [copiedPart, currentActivityTree], + ); const handleAddComponent = useCallback( (partComponentType: string) => () => { diff --git a/assets/src/apps/authoring/components/PropertyEditor/PropertyEditor.tsx b/assets/src/apps/authoring/components/PropertyEditor/PropertyEditor.tsx index 296a42dc419..24229ac06e4 100644 --- a/assets/src/apps/authoring/components/PropertyEditor/PropertyEditor.tsx +++ b/assets/src/apps/authoring/components/PropertyEditor/PropertyEditor.tsx @@ -22,7 +22,9 @@ interface PropertyEditorProps { uiSchema: UiSchema; onChangeHandler: (changes: unknown) => void; value: unknown; + onClickHandler?: (changes: unknown) => void; triggerOnChange?: boolean | string[]; + onfocusHandler?: (changes: boolean) => void; } const widgets: any = { @@ -46,6 +48,7 @@ const PropertyEditor: React.FC = ({ value, onChangeHandler, triggerOnChange = false, + onfocusHandler, }) => { const [formData, setFormData] = useState(value); @@ -92,6 +95,11 @@ const PropertyEditor: React.FC = ({ } } }} + onFocus={() => { + if (onfocusHandler) { + onfocusHandler(false); + } + }} onBlur={(key, changed) => { // key will look like root_Position_x // changed will be the new value @@ -106,6 +114,8 @@ const PropertyEditor: React.FC = ({ // console.log('ONBLUR TRIGGER SAVE'); onChangeHandler(formData); + } else if (onfocusHandler) { + onfocusHandler(true); } }} uiSchema={uiSchema} diff --git a/assets/src/apps/authoring/components/PropertyEditor/custom/MCQCustomErrorFeedbackAuthoring.tsx b/assets/src/apps/authoring/components/PropertyEditor/custom/MCQCustomErrorFeedbackAuthoring.tsx index 56a44e8a2ce..8168e48fce0 100644 --- a/assets/src/apps/authoring/components/PropertyEditor/custom/MCQCustomErrorFeedbackAuthoring.tsx +++ b/assets/src/apps/authoring/components/PropertyEditor/custom/MCQCustomErrorFeedbackAuthoring.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { getNodeText } from '../../../../../components/parts/janus-mcq/mcq-util'; import { selectCurrentActivityTree } from '../../../../delivery/store/features/groups/selectors/deck'; -import { selectCurrentSelection } from '../../../store/parts/slice'; +import { selectCurrentSelection, setCurrentPartPropertyFocus } from '../../../store/parts/slice'; /* This component handles editing advanced feedback for a question type that has a fixed set of options. @@ -115,15 +115,22 @@ const OptionFeedback: React.FC = ({ feedback, onChange, }) => { + const dispatch = useDispatch(); const labelOption = option || `Option ${index + 1}`; return (
{ + onBlur(); + dispatch(setCurrentPartPropertyFocus({ focus: true })); + }} className="form-control" value={feedback} onChange={(e) => onChange(e.target.value)} + onFocus={() => { + dispatch(setCurrentPartPropertyFocus({ focus: false })); + }} />
); diff --git a/assets/src/apps/authoring/components/PropertyEditor/custom/MCQOptionsEditor.tsx b/assets/src/apps/authoring/components/PropertyEditor/custom/MCQOptionsEditor.tsx index 86b698f86a3..6fd2dd24304 100644 --- a/assets/src/apps/authoring/components/PropertyEditor/custom/MCQOptionsEditor.tsx +++ b/assets/src/apps/authoring/components/PropertyEditor/custom/MCQOptionsEditor.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useState } from 'react'; import { Modal } from 'react-bootstrap'; +import { useDispatch } from 'react-redux'; +import { setCurrentPartPropertyFocus } from 'apps/authoring/store/parts/slice'; import { useToggle } from '../../../../../components/hooks/useToggle'; import { getNodeText } from '../../../../../components/parts/janus-mcq/mcq-util'; import { QuillEditor } from '../../../../../components/parts/janus-text-flow/QuillEditor'; @@ -76,6 +78,7 @@ const OptionsEditor: React.FC<{ }> = ({ value, onChange, onDelete }) => { const [editorOpen, , openEditor, closeEditor] = useToggle(false); const [tempValue, setTempValue] = useState<{ value: OptionsNodes }>({ value: [] }); + const dispatch = useDispatch(); const onSave = useCallback(() => { closeEditor(); @@ -85,11 +88,13 @@ const OptionsEditor: React.FC<{ }; onChange(newValue); console.info('onSave', newValue); + dispatch(setCurrentPartPropertyFocus({ focus: true })); }, [closeEditor, onChange, tempValue.value, value]); const onEdit = useCallback(() => { openEditor(); setTempValue({ value: value.nodes }); + dispatch(setCurrentPartPropertyFocus({ focus: false })); }, [openEditor, value.nodes]); return ( diff --git a/assets/src/apps/authoring/components/RightMenu/PartPropertyEditor.tsx b/assets/src/apps/authoring/components/RightMenu/PartPropertyEditor.tsx index a8205be1df3..178a3410f39 100644 --- a/assets/src/apps/authoring/components/RightMenu/PartPropertyEditor.tsx +++ b/assets/src/apps/authoring/components/RightMenu/PartPropertyEditor.tsx @@ -10,7 +10,7 @@ import { clone } from '../../../../utils/common'; import { IActivity } from '../../../delivery/store/features/activities/slice'; import { saveActivity } from '../../store/activities/actions/saveActivity'; import { selectAppMode, setCopiedPart, setRightPanelActiveTab } from '../../store/app/slice'; -import { setCurrentSelection } from '../../store/parts/slice'; +import { setCurrentPartPropertyFocus, setCurrentSelection } from '../../store/parts/slice'; import ConfirmDelete from '../Modal/DeleteConfirmationModal'; import PropertyEditor from '../PropertyEditor/PropertyEditor'; import AccordionTemplate from '../PropertyEditor/custom/AccordionTemplate'; @@ -317,6 +317,13 @@ export const PartPropertyEditor: React.FC = ({ [currentActivity.id, currentPartInstance, currentPartSelection, dispatch], ); + const componentPropertyFocusHandler = useCallback( + (partPropertyElementFocus: boolean) => { + dispatch(setCurrentPartPropertyFocus({ focus: partPropertyElementFocus })); + }, + [currentActivity.id, currentPartInstance, currentPartSelection, dispatch], + ); + if (!partDef) return null; return ( @@ -360,6 +367,7 @@ export const PartPropertyEditor: React.FC = ({ value={currentComponentData} onChangeHandler={componentPropertyChangeHandler} triggerOnChange={true} + onfocusHandler={componentPropertyFocusHandler} /> ); diff --git a/assets/src/apps/authoring/components/SequenceEditor/SequenceEditor.tsx b/assets/src/apps/authoring/components/SequenceEditor/SequenceEditor.tsx index 62a7ce199c1..0f613e2ef5f 100644 --- a/assets/src/apps/authoring/components/SequenceEditor/SequenceEditor.tsx +++ b/assets/src/apps/authoring/components/SequenceEditor/SequenceEditor.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Accordion, Dropdown, ListGroup, OverlayTrigger, Tooltip } from 'react-bootstrap'; import { useDispatch, useSelector } from 'react-redux'; import { saveActivity } from 'apps/authoring/store/activities/actions/saveActivity'; +import { setCurrentPartPropertyFocus } from 'apps/authoring/store/parts/slice'; import { clone } from 'utils/common'; import guid from 'utils/guid'; import { useToggle } from '../../../../components/hooks/useToggle'; @@ -88,6 +89,7 @@ const SequenceEditor: React.FC = (props: any) => { const handleItemClick = (e: any, entry: SequenceEntry) => { e.stopPropagation(); + dispatch(setCurrentPartPropertyFocus({ focus: true })); dispatch(setCurrentActivityFromSequence(entry.custom.sequenceId)); dispatch( setRightPanelActiveTab({ @@ -505,8 +507,14 @@ const SequenceEditor: React.FC = (props: any) => { custom: { ...itemToRename.custom, sequenceName: e.target.value }, }) } - onFocus={(e) => e.target.select()} - onBlur={() => handleRenameItem(item)} + onFocus={(e) => { + e.target.select(); + dispatch(setCurrentPartPropertyFocus({ focus: false })); + }} + onBlur={() => { + handleRenameItem(item); + dispatch(setCurrentPartPropertyFocus({ focus: true })); + }} onKeyDown={(e) => { if (e.key === 'Enter') handleRenameItem(item); if (e.key === 'Escape') setItemToRename(undefined); diff --git a/assets/src/apps/authoring/store/parts/slice.ts b/assets/src/apps/authoring/store/parts/slice.ts index 4bf9b71c3f6..3981743a79e 100644 --- a/assets/src/apps/authoring/store/parts/slice.ts +++ b/assets/src/apps/authoring/store/parts/slice.ts @@ -4,10 +4,12 @@ import PartsSlice from './name'; export interface PartState { currentSelection: string; + currentPartPropertyFocus?: boolean; } const initialState: PartState = { currentSelection: '', + currentPartPropertyFocus: false, }; const slice: Slice = createSlice({ @@ -17,12 +19,19 @@ const slice: Slice = createSlice({ setCurrentSelection(state, action: PayloadAction<{ selection: string }>) { state.currentSelection = action.payload.selection; }, + setCurrentPartPropertyFocus(state, action: PayloadAction<{ focus: boolean }>) { + state.currentPartPropertyFocus = action.payload.focus; + }, }, }); -export const { setCurrentSelection } = slice.actions; +export const { setCurrentSelection, setCurrentPartPropertyFocus } = slice.actions; export const selectState = (state: AuthoringRootState): PartState => state[PartsSlice] as PartState; export const selectCurrentSelection = createSelector(selectState, (s) => s.currentSelection); +export const selectCurrentPartPropertyFocus = createSelector( + selectState, + (s) => s.currentPartPropertyFocus, +); export default slice.reducer; diff --git a/assets/src/apps/delivery/components/NotificationContext.tsx b/assets/src/apps/delivery/components/NotificationContext.tsx index c06b85388fb..076d0c794b1 100644 --- a/assets/src/apps/delivery/components/NotificationContext.tsx +++ b/assets/src/apps/delivery/components/NotificationContext.tsx @@ -11,6 +11,7 @@ export enum NotificationType { CONFIGURE = 'configure', CONFIGURE_SAVE = 'configureSave', CONFIGURE_CANCEL = 'configureCancel', + CHECK_SHORTCUT_ACTIONS = 'checkShortcutActions', } type UnsubscribeFn = () => void; diff --git a/assets/src/components/activities/adaptive/AdaptiveAuthoring.tsx b/assets/src/components/activities/adaptive/AdaptiveAuthoring.tsx index 9ecbab5b1b2..2d8a6c0be22 100644 --- a/assets/src/components/activities/adaptive/AdaptiveAuthoring.tsx +++ b/assets/src/components/activities/adaptive/AdaptiveAuthoring.tsx @@ -35,6 +35,7 @@ const Adaptive = ( NotificationType.CONFIGURE, NotificationType.CONFIGURE_CANCEL, NotificationType.CONFIGURE_SAVE, + NotificationType.CHECK_SHORTCUT_ACTIONS, ]; const notifications = notificationsHandled.map((notificationType: NotificationType) => { const handler = (e: any) => { diff --git a/assets/src/components/activities/adaptive/components/authoring/LayoutEditor.tsx b/assets/src/components/activities/adaptive/components/authoring/LayoutEditor.tsx index d44604ff309..e3f64b0c2c9 100644 --- a/assets/src/components/activities/adaptive/components/authoring/LayoutEditor.tsx +++ b/assets/src/components/activities/adaptive/components/authoring/LayoutEditor.tsx @@ -12,7 +12,6 @@ import { NotificationType, subscribeToNotification, } from 'apps/delivery/components/NotificationContext'; -import { useKeyDown } from 'hooks/useKeyDown'; import { clone } from 'utils/common'; import { contexts } from '../../../../../types/applicationContext'; import PartComponent from '../common/PartComponent'; @@ -329,6 +328,14 @@ const LayoutEditor: React.FC = (props) => { } }; + const handleShortcutActionNotifications = (payload: any) => { + const { type } = payload; + if (type === 'Delete') { + setShowConfirmDelete(true); + } else if (type === 'Copy') { + handleCopyComponent(); + } + }; useEffect(() => { if (!pusher) { return; @@ -337,6 +344,7 @@ const LayoutEditor: React.FC = (props) => { NotificationType.CONFIGURE, NotificationType.CONFIGURE_CANCEL, NotificationType.CONFIGURE_SAVE, + NotificationType.CHECK_SHORTCUT_ACTIONS, ]; const notifications = notificationsHandled.map((notificationType: NotificationType) => { const handler = (payload: any) => { @@ -347,6 +355,9 @@ const LayoutEditor: React.FC = (props) => { case NotificationType.CONFIGURE_CANCEL: handlePartCancelConfigure(payload); break; + case NotificationType.CHECK_SHORTCUT_ACTIONS: + handleShortcutActionNotifications(payload); + break; case NotificationType.CONFIGURE_SAVE: // maybe layout editor should *only* do this for both cancel and save // because the part should also catch this event and call the onCancelConfigurePart @@ -364,7 +375,7 @@ const LayoutEditor: React.FC = (props) => { unsub(); }); }; - }, [configurePartId, handlePartCancelConfigure, pusher]); + }, [configurePartId, handlePartCancelConfigure, selectedPartAndCapabilities, pusher]); const containerRef = useRef(null); @@ -394,22 +405,6 @@ const LayoutEditor: React.FC = (props) => { [dragSize, isDragging, selectedPartId], ); - useKeyDown( - () => { - if (selectedPartAndCapabilities && !configurePartId.length) { - setShowConfirmDelete(true); - } - }, - ['Delete', 'Backspace'], - { ctrlKey: true }, - [selectedPartAndCapabilities, configurePartId], - ); - useKeyDown(handleCopyComponent, ['KeyC'], { ctrlKey: true }, [ - selectedPartAndCapabilities, - parts, - handleCopyComponent, - ]); - return parts && parts.length ? (
diff --git a/assets/src/components/activities/likert/LikertAuthoring.tsx b/assets/src/components/activities/likert/LikertAuthoring.tsx index a1188a73b1c..ebe4b289c16 100644 --- a/assets/src/components/activities/likert/LikertAuthoring.tsx +++ b/assets/src/components/activities/likert/LikertAuthoring.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { VegaLite, VisualizationSpec } from 'react-vega'; +// import { VegaLite, VisualizationSpec } from 'react-vega'; import { Choices as ChoicesAuthoring } from 'components/activities/common/choices/authoring/ChoicesAuthoring'; import { Hints } from 'components/activities/common/hints/authoring/HintsAuthoringConnected'; import { Stem } from 'components/activities/common/stem/authoring/StemAuthoringConnected'; @@ -37,84 +37,84 @@ const Likert = (props: AuthoringElementProps) => { projectSlug: projectSlug, }); - const transformedData = { - values: model.choices - .filter((choice) => choice.frequency > 0) - .map((choice) => ({ - label: - 'text' in choice.content[0].children[0] ? choice.content[0].children[0].text : 'No text', - value: choice.frequency, - })), - }; + // const transformedData = { + // values: model.choices + // .filter((choice) => choice.frequency > 0) + // .map((choice) => ({ + // label: + // 'text' in choice.content[0].children[0] ? choice.content[0].children[0].text : 'No text', + // value: choice.frequency, + // })), + // }; - const colorsList = [ - 'rgb(198, 207, 241)', - 'rgb(220, 198, 224)', - 'rgb(202, 233, 198)', - 'rgb(209, 196, 233)', - 'rgb(160, 219, 206)', - 'rgb(242, 205, 176)', - 'rgb(187, 223, 179)', - 'rgb(231, 174, 125)', - 'rgb(187, 192, 206)', - 'rgb(241, 196, 198)', - 'rgb(194, 220, 232)', - 'rgb(236, 217, 203)', - 'rgb(172, 225, 240)', - 'rgb(247, 214, 199)', - 'rgb(207, 241, 206)', - ]; + // const colorsList = [ + // 'rgb(198, 207, 241)', + // 'rgb(220, 198, 224)', + // 'rgb(202, 233, 198)', + // 'rgb(209, 196, 233)', + // 'rgb(160, 219, 206)', + // 'rgb(242, 205, 176)', + // 'rgb(187, 223, 179)', + // 'rgb(231, 174, 125)', + // 'rgb(187, 192, 206)', + // 'rgb(241, 196, 198)', + // 'rgb(194, 220, 232)', + // 'rgb(236, 217, 203)', + // 'rgb(172, 225, 240)', + // 'rgb(247, 214, 199)', + // 'rgb(207, 241, 206)', + // ]; - const spec: VisualizationSpec = { - $schema: 'https://vega.github.io/schema/vega-lite/v5.json', - data: transformedData, - title: { - text: model.activityTitle, - subtitle: model.items.map((item) => - 'text' in item.content[0].children[0] ? item.content[0].children[0].text : 'No text', - ), - subtitlePadding: 10, - }, - mark: { type: 'bar' }, - width: 500, - height: 200, - encoding: { - x: { - aggregate: 'sum', - field: 'value', - type: 'quantitative', - axis: { title: null }, - }, - y: { - field: 'category', - type: 'ordinal', - axis: { title: null, labels: false }, - }, - color: { - field: 'label', - type: 'nominal', - scale: { - range: colorsList, - }, - }, - tooltip: [ - { field: 'value', type: 'quantitative', title: 'Value' }, - { field: 'label', type: 'nominal', title: 'Text' }, - ], - }, - config: { - view: { stroke: 'transparent' }, - axisX: { labels: true }, - legend: { - orient: 'right', - title: null, - padding: 10, - rowPadding: 10, - }, - }, - }; + // const spec: VisualizationSpec = { + // $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + // data: transformedData, + // title: { + // text: model.activityTitle, + // subtitle: model.items.map((item) => + // 'text' in item.content[0].children[0] ? item.content[0].children[0].text : 'No text', + // ), + // subtitlePadding: 10, + // }, + // mark: { type: 'bar' }, + // width: 500, + // height: 200, + // encoding: { + // x: { + // aggregate: 'sum', + // field: 'value', + // type: 'quantitative', + // axis: { title: null }, + // }, + // y: { + // field: 'category', + // type: 'ordinal', + // axis: { title: null, labels: false }, + // }, + // color: { + // field: 'label', + // type: 'nominal', + // scale: { + // range: colorsList, + // }, + // }, + // tooltip: [ + // { field: 'value', type: 'quantitative', title: 'Value' }, + // { field: 'label', type: 'nominal', title: 'Text' }, + // ], + // }, + // config: { + // view: { stroke: 'transparent' }, + // axisX: { labels: true }, + // legend: { + // orient: 'right', + // title: null, + // padding: 10, + // rowPadding: 10, + // }, + // }, + // }; - const dataLong = transformedData.values.length; + // const dataLong = transformedData.values.length; return ( @@ -125,6 +125,24 @@ const Likert = (props: AuthoringElementProps) => {

Choices:

+
+ } + choices={model.choices} + setAll={(choices: ActivityTypes.Choice[]) => dispatch(Choices.setAll(choices))} + onEdit={(id, content) => dispatch(Choices.setContent(id, content))} + onChangeEditorType={(id, editorType) => + dispatch(Choices.setEditor(id, editorType)) + } + addOne={() => dispatch(LikertActions.addChoice())} + onRemove={(id) => dispatch(LikertActions.removeChoice(id))} + onChangeEditorTextDirection={(id, textDirection) => + dispatch(Choices.setTextDirection(id, textDirection)) + } + /> +
+
+ {/*
0 ? 'w-full lg:w-1/2' : 'w-full'}`}> } @@ -151,7 +169,7 @@ const Likert = (props: AuthoringElementProps) => {
)} -
+
*/}
now) { - const timeOutInMs = timeOutInMins * 60 * 1000; - const now = new Date().getTime(); - - const timeLeft = effectiveTimeInMs - now; - const realDeadlineInMs = timeLeft < timeOutInMs ? now + timeLeft : timeOutInMs + startTimeInMs; - + if (now < effectiveTimeInMs) { const interval = setInterval(function () { const now = new Date().getTime(); - const timerMessage = formatTimerMessage(realDeadlineInMs, now); - update(timerId, timerMessage); - if (hasExpired(realDeadlineInMs, now)) { + if (now < endTimeInMs) { + // We are still within the time limit + const timerMessage = formatTimerMessage(endTimeInMs, now); + update(timerId, timerMessage); + } else if (now >= endTimeInMs && now < graceEndTimeInMs) { + // We are within the grace period + update(timerId, ''); + } else { + // Both the time limit and grace period have expired clearInterval(interval); update(timerId, ''); - if (autoSubmit) { - const submitButton = document.getElementById(submitButtonId); - submitButton ? submitButton.click() : console.error('Submit button not found'); - } else { - update(timerId, 'This is a late submission'); - } + autoSubmit ? do_auto_submit(submitButtonId) : update(timerId, 'This is a late submission'); } }, 1000); + } else { + autoSubmit ? do_auto_submit(submitButtonId) : update(timerId, 'This is a late submission'); } } +function do_auto_submit(submitButtonId: string) { + const submitButton = document.getElementById(submitButtonId); + submitButton ? submitButton.click() : console.error('Submit button not found'); +} + function update(id: string, content: string) { const element = document.getElementById(id); element ? (element.innerHTML = content) : console.error('Element with id ' + id + ' not found'); diff --git a/assets/src/hooks/end_date_timer.ts b/assets/src/hooks/end_date_timer.ts index 9427df59d1a..618399f09e6 100644 --- a/assets/src/hooks/end_date_timer.ts +++ b/assets/src/hooks/end_date_timer.ts @@ -1,18 +1,13 @@ export const EndDateTimer = { mounted() { const { timerId, submitButtonId, effectiveTimeInMs, autoSubmit } = this.el.dataset; + + // Parse the dataset values const parsedEffectiveTimeInMs = parseInt(effectiveTimeInMs, 10); const parsedAutoSubmit = autoSubmit === 'true'; + this.isPageHidden = false; endDateTimer(timerId, submitButtonId, parsedEffectiveTimeInMs, parsedAutoSubmit); - window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this, submitButtonId)); - }, - destroyed() { - window.removeEventListener('beforeunload', this.handleBeforeUnload.bind(this)); - }, - handleBeforeUnload(submitButtonId: string) { - const submitButton = document.getElementById(submitButtonId); - submitButton ? submitButton.click() : console.error('Submit button not found'); }, }; @@ -35,10 +30,12 @@ function endDateTimer( const minutes = Math.floor(distance / (1000 * 60)); const seconds = Math.floor((distance % (1000 * 60)) / 1000); + // Update the timer display if less than 5 minutes remain if (minutes < 5) { update(timerId, 'Time remaining: ' + minutes + 'm ' + seconds + 's '); } + // Check if the time has expired if (distance < 0) { clearInterval(interval); update(timerId, ''); diff --git a/config/config.exs b/config/config.exs index 36ee3698ef1..1ee2e7cfb28 100644 --- a/config/config.exs +++ b/config/config.exs @@ -170,6 +170,7 @@ config :oli, Oban, updates: 10, grades: 30, auto_submit: 3, + project_export: 3, analytics_export: 3, datashop_export: 3, objectives: 3 diff --git a/config/runtime.exs b/config/runtime.exs index 5c14cba2d1e..501474226a8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -383,6 +383,7 @@ if config_env() == :prod do auto_submit: String.to_integer(System.get_env("OBAN_QUEUE_SIZE_AUTOSUBMIT", "3")), analytics_export: String.to_integer(System.get_env("OBAN_QUEUE_SIZE_ANALYTICS", "1")), datashop_export: String.to_integer(System.get_env("OBAN_QUEUE_SIZE_DATASHOP", "1")), + project_export: String.to_integer(System.get_env("OBAN_QUEUE_SIZE_PROJECT_EXPORT", "3")), objectives: String.to_integer(System.get_env("OBAN_QUEUE_SIZE_OBJECTIVES", "3")) ] end diff --git a/lib/oli/activities/reports/providers/OliLikert.ex b/lib/oli/activities/reports/providers/OliLikert.ex index ffdb5992bb7..9ffe7ebace9 100644 --- a/lib/oli/activities/reports/providers/OliLikert.ex +++ b/lib/oli/activities/reports/providers/OliLikert.ex @@ -126,7 +126,9 @@ defmodule Oli.Activities.Reports.Providers.OliLikert do group = Map.get(a, "group") {:ok, choice} = - Enum.at(choices, r - 1) |> JSONPointer.get("/content/0/children/0/text") + if is_integer(r), + do: Enum.at(choices, r - 1) |> JSONPointer.get("/content/0/children/0/text"), + else: {:ok, ""} {color, c} = case Map.get(Map.get(c, :colors), group) do diff --git a/lib/oli/authoring/broadcasting/broadcaster.ex b/lib/oli/authoring/broadcasting/broadcaster.ex index 68ac02018a0..101e3e61be5 100644 --- a/lib/oli/authoring/broadcasting/broadcaster.ex +++ b/lib/oli/authoring/broadcasting/broadcaster.ex @@ -115,6 +115,17 @@ defmodule Oli.Authoring.Broadcaster do ) end + @doc """ + Broadcasts a project export status update + """ + def broadcast_project_export_status(project_slug, status) do + PubSub.broadcast( + Oli.PubSub, + message_project_export_status(project_slug), + {:project_export_status, status} + ) + end + @doc """ Broadcasts a raw analytics export status update """ diff --git a/lib/oli/authoring/broadcasting/messages.ex b/lib/oli/authoring/broadcasting/messages.ex index c199933b66f..769b3fc4dff 100644 --- a/lib/oli/authoring/broadcasting/messages.ex +++ b/lib/oli/authoring/broadcasting/messages.ex @@ -41,6 +41,10 @@ defmodule Oli.Authoring.Broadcaster.Messages do ["lock_released", project(project_slug), resource(resource_id)] |> join end + def message_project_export_status(project_slug) do + ["project_export_status", project(project_slug)] |> join + end + def message_analytics_export_status(project_slug) do ["analytics_export_status", project(project_slug)] |> join end diff --git a/lib/oli/authoring/broadcasting/subscriber.ex b/lib/oli/authoring/broadcasting/subscriber.ex index 2df8d0824c1..c2ce4244361 100644 --- a/lib/oli/authoring/broadcasting/subscriber.ex +++ b/lib/oli/authoring/broadcasting/subscriber.ex @@ -43,6 +43,10 @@ defmodule Oli.Authoring.Broadcaster.Subscriber do PubSub.subscribe(Oli.PubSub, message_lock_released(project_slug, resource_id)) end + def subscribe_to_project_export_status(project_slug) do + PubSub.subscribe(Oli.PubSub, message_project_export_status(project_slug)) + end + def subscribe_to_analytics_export_status(project_slug) do PubSub.subscribe(Oli.PubSub, message_analytics_export_status(project_slug)) end diff --git a/lib/oli/authoring/course.ex b/lib/oli/authoring/course.ex index 3b504c622d5..72f15e26304 100644 --- a/lib/oli/authoring/course.ex +++ b/lib/oli/authoring/course.ex @@ -340,6 +340,18 @@ defmodule Oli.Authoring.Course do |> Repo.update() end + @doc """ + Updates the latest_export_snapshot_url and latest_export_snapshot_timestamp for the given project. + """ + def update_project_latest_export_url(project_slug, url, timestamp) do + get_project_by_slug(project_slug) + |> Project.changeset(%{ + latest_export_url: url, + latest_export_timestamp: timestamp + }) + |> Repo.update() + end + def get_family!(id), do: Repo.get!(Family, id) def update_family(%Family{} = family, attrs) do diff --git a/lib/oli/authoring/course/project.ex b/lib/oli/authoring/course/project.ex index f77da3d1cd3..6767dbd632b 100644 --- a/lib/oli/authoring/course/project.ex +++ b/lib/oli/authoring/course/project.ex @@ -18,6 +18,8 @@ defmodule Oli.Authoring.Course.Project do field(:has_experiments, :boolean, default: false) field(:legacy_svn_root, :string) field(:allow_ecl_content_type, :boolean, default: false) + field(:latest_export_url, :string) + field(:latest_export_timestamp, :utc_datetime) field(:latest_analytics_snapshot_url, :string) field(:latest_analytics_snapshot_timestamp, :utc_datetime) field(:latest_datashop_snapshot_url, :string) @@ -27,6 +29,7 @@ defmodule Oli.Authoring.Course.Project do field(:welcome_title, :map, default: %{}) field(:encouraging_subtitle, :string) + field(:auto_update_sections, :boolean, default: true) embeds_one(:customizations, CustomLabels, on_replace: :delete) embeds_one(:attributes, ProjectAttributes, on_replace: :delete) @@ -82,13 +85,16 @@ defmodule Oli.Authoring.Course.Project do :analytics_version, :publisher_id, :required_survey_resource_id, + :latest_export_url, + :latest_export_timestamp, :latest_analytics_snapshot_url, :latest_analytics_snapshot_timestamp, :latest_datashop_snapshot_url, :latest_datashop_snapshot_timestamp, :allow_transfer_payment_codes, :welcome_title, - :encouraging_subtitle + :encouraging_subtitle, + :auto_update_sections ]) |> cast_embed(:attributes, required: false) |> cast_embed(:customizations, required: false) @@ -112,7 +118,8 @@ defmodule Oli.Authoring.Course.Project do :has_experiments, :legacy_svn_root, :allow_ecl_content_type, - :publisher_id + :publisher_id, + :auto_update_sections ]) |> validate_required([:title, :version, :family_id, :publisher_id]) |> foreign_key_constraint(:publisher_id) diff --git a/lib/oli/authoring/project_export_worker.ex b/lib/oli/authoring/project_export_worker.ex new file mode 100644 index 00000000000..bb81f7abb08 --- /dev/null +++ b/lib/oli/authoring/project_export_worker.ex @@ -0,0 +1,97 @@ +defmodule Oli.Authoring.ProjectExportWorker do + use Oban.Worker, + queue: :project_export, + priority: 3, + max_attempts: 1 + + require Logger + + alias Oli.Utils + alias Oli.Authoring.Broadcaster + alias Oli.Authoring.Course + alias Oli.Authoring.Course.Project + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"project_slug" => project_slug} = _args}) do + try do + {full_upload_url, timestamp} = export(project_slug) + + # notify subscribers that the export is available + Broadcaster.broadcast_project_export_status( + project_slug, + {:available, full_upload_url, timestamp} + ) + rescue + e -> + # notify subscribers that the export failed + Broadcaster.broadcast_project_export_status( + project_slug, + {:error, e} + ) + + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + reraise e, __STACKTRACE__ + end + + :ok + end + + def export(project_slug) do + timestamp = DateTime.utc_now() + + project = Course.get_project_by_slug(project_slug) + + export_zip_content = Oli.Interop.Export.export(project) + + random_string = Oli.Utils.random_string(16) + + filename = "export_#{project_slug}.zip" + + bucket_name = Application.fetch_env!(:oli, :s3_media_bucket_name) + project_export_path = Path.join(["exports", project_slug, random_string, filename]) + + {:ok, full_upload_url} = + Utils.S3Storage.put(bucket_name, project_export_path, export_zip_content) + + # update the project's last_exported_at timestamp + Course.update_project_latest_export_url(project_slug, full_upload_url, timestamp) + + {full_upload_url, timestamp} + end + + @doc """ + Generates a project export for the given project if one is not already in progress + """ + def generate_project_export(project) do + case project_export_status(project) do + {:in_progress} -> + {:error, "Project export is already in progress"} + + _ -> + %{project_slug: project.slug} + |> Oli.Authoring.ProjectExportWorker.new() + |> Oban.insert() + end + end + + def project_export_status(project) do + if Course.export_in_progress?(project.slug, "project_export") do + # export is in progress + {:in_progress} + else + case project do + # export is created and completed + %Project{ + latest_export_url: export_url, + latest_export_timestamp: export_timestamp + } + when not is_nil(export_url) and not is_nil(export_timestamp) -> + {:available, export_url, export_timestamp} + + # export has not been created yet + _ -> + {:not_available} + end + end + end +end diff --git a/lib/oli/delivery.ex b/lib/oli/delivery.ex index a376681cf0d..14b5e3158f8 100644 --- a/lib/oli/delivery.ex +++ b/lib/oli/delivery.ex @@ -11,6 +11,7 @@ defmodule Oli.Delivery do alias Oli.Repo alias Oli.Publishing.{DeliveryResolver, PublishedResource} alias Oli.Delivery.Attempts.Core.{ResourceAttempt, ActivityAttempt, ResourceAccess} + alias Oli.Delivery.ResearchConsent import Ecto.Query, warn: false import Oli.Utils @@ -433,4 +434,29 @@ defmodule Oli.Delivery do |> Enum.all?(&(elem(&1, 1) == :evaluated)) end end + + @doc """ + Returns the research consent form setting. + + This record is created during migration so a single record is always expected to exist. + """ + def get_research_consent_form_setting() do + Repo.one(ResearchConsent) + |> Map.get(:research_consent) + end + + @doc """ + Updates the research consent form setting. + """ + def update_research_consent_form_setting(research_consent) do + case Repo.one(ResearchConsent) do + nil -> + Repo.insert!(%ResearchConsent{research_consent: research_consent}) + + %ResearchConsent{} = research_consent_form -> + research_consent_form + |> ResearchConsent.changeset(%{research_consent: research_consent}) + |> Repo.update() + end + end end diff --git a/lib/oli/delivery/attempts/core.ex b/lib/oli/delivery/attempts/core.ex index 2ce4297995a..78ee8aa6200 100644 --- a/lib/oli/delivery/attempts/core.ex +++ b/lib/oli/delivery/attempts/core.ex @@ -594,7 +594,7 @@ defmodule Oli.Delivery.Attempts.Core do from(aa in ActivityAttempt, left_join: aa2 in ActivityAttempt, on: - aa.resource_attempt_id == aa2.resource_attempt_id and + aa.resource_id == aa2.resource_id and aa.id < aa2.id, join: ra in ResourceAttempt, on: ra.id == aa.resource_attempt_id, diff --git a/lib/oli/delivery/research_consent.ex b/lib/oli/delivery/research_consent.ex new file mode 100644 index 00000000000..af9383869e4 --- /dev/null +++ b/lib/oli/delivery/research_consent.ex @@ -0,0 +1,15 @@ +defmodule Oli.Delivery.ResearchConsent do + use Ecto.Schema + import Ecto.Changeset + + schema "research_consent" do + field :research_consent, Ecto.Enum, values: [:oli_form, :no_form], default: :oli_form + end + + @doc false + def changeset(research_consent, attrs) do + research_consent + |> cast(attrs, [:research_consent]) + |> validate_required([:research_consent]) + end +end diff --git a/lib/oli/delivery/sections.ex b/lib/oli/delivery/sections.ex index d39e78c6781..673c02b1d27 100644 --- a/lib/oli/delivery/sections.ex +++ b/lib/oli/delivery/sections.ex @@ -376,7 +376,10 @@ defmodule Oli.Delivery.Sections do enrollment_context_roles end) - Repo.insert_all(EnrollmentContextRole, enrollment_context_roles, on_conflict: :nothing) + Repo.insert_all(EnrollmentContextRole, enrollment_context_roles, + on_conflict: :nothing, + conflict_target: [:enrollment_id, :context_role_id] + ) {:ok, enrollments} end) diff --git a/lib/oli/delivery/sections/browse.ex b/lib/oli/delivery/sections/browse.ex index 24c0e2766d4..4abf8f7c8f1 100644 --- a/lib/oli/delivery/sections/browse.ex +++ b/lib/oli/delivery/sections/browse.ex @@ -106,7 +106,7 @@ defmodule Oli.Delivery.Sections.Browse do from e in Enrollment, join: ecr in EnrollmentContextRole, on: ecr.enrollment_id == e.id, - where: ecr.context_role_id == ^student_role_id, + where: ecr.context_role_id == ^student_role_id and e.status == :enrolled, select: %{ id: e.id, section_id: e.section_id diff --git a/lib/oli/institutions.ex b/lib/oli/institutions.ex index 152aa5a07b0..9383e3b3d01 100644 --- a/lib/oli/institutions.ex +++ b/lib/oli/institutions.ex @@ -81,6 +81,28 @@ defmodule Oli.Institutions do |> Repo.preload(:author) end + @doc """ + Returns the institution that an LTI user is associated with. + """ + def get_institution_by_lti_user(user) do + # using enrollment records, we can infer the user's institution. This is because + # an LTI user can be enrolled in multiple sections, but all sections must be from + # the same institution. + from(e in Enrollment, + join: s in Section, + on: e.section_id == s.id, + join: u in User, + on: e.user_id == u.id, + join: institution in Institution, + on: s.institution_id == institution.id, + where: u.id == ^user.id and s.status == :active and e.status == :enrolled, + limit: 1, + select: institution + ) + |> Repo.all() + |> List.first() + end + @doc """ Gets an institution by clauses. Will raise an error if more than one matches the criteria. diff --git a/lib/oli_web/components/delivery/layouts.ex b/lib/oli_web/components/delivery/layouts.ex index 05561fd1186..bfc1db2cdc4 100644 --- a/lib/oli_web/components/delivery/layouts.ex +++ b/lib/oli_web/components/delivery/layouts.ex @@ -675,12 +675,15 @@ defmodule OliWeb.Components.Delivery.Layouts do end attr :sidebar_expanded, :boolean, default: true + attr :target_workspace, :atom, default: :student_workspace def exit_course_button(assigns) do ~H""" <.link id="exit_course_button" - navigate={~p"/sections"} + navigate={ + ~p"/sections?#{%{sidebar_expanded: @sidebar_expanded, active_workspace: @target_workspace}}" + } class="w-full h-11 flex-col justify-center items-center flex hover:no-underline text-black/70 hover:text-black/90 dark:text-gray-400 hover:dark:text-white stroke-black/70 hover:stroke-black/90 dark:stroke-[#B8B4BF] hover:dark:stroke-white" >
diff --git a/lib/oli_web/components/delivery/user_account.ex b/lib/oli_web/components/delivery/user_account.ex index 470601a0713..dd31679814a 100644 --- a/lib/oli_web/components/delivery/user_account.ex +++ b/lib/oli_web/components/delivery/user_account.ex @@ -6,6 +6,9 @@ defmodule OliWeb.Components.Delivery.UserAccount do alias Phoenix.LiveView.JS alias Oli.Accounts.{User, Author} alias OliWeb.Router.Helpers, as: Routes + alias Oli.Institutions + alias Oli.Institutions.Institution + alias Oli.Delivery alias Oli.Delivery.Sections alias Oli.Delivery.Sections.Section alias OliWeb.Common.SessionContext @@ -92,6 +95,7 @@ defmodule OliWeb.Components.Delivery.UserAccount do <.menu_divider /> <.menu_item_timezone_selector id={"#{@id}-tz-selector"} ctx={@ctx} /> <.menu_divider /> + <.maybe_research_consent_link ctx={@ctx} /> <.menu_item_link href={Routes.session_path(OliWeb.Endpoint, :signout, type: :user)} method={:delete} @@ -116,6 +120,7 @@ defmodule OliWeb.Components.Delivery.UserAccount do Create account or sign in <.menu_divider /> + <.maybe_research_consent_link ctx={@ctx} /> <.menu_item_link href={signout_path(@ctx)} method={:delete}> <%= if @ctx.user.guest, do: "Leave course", else: "Sign out" %> @@ -299,6 +304,19 @@ defmodule OliWeb.Components.Delivery.UserAccount do attr(:ctx, SessionContext, required: true) + defp maybe_research_consent_link(assigns) do + ~H""" + <%= if show_research_consent_link?(@ctx.user) do %> + <.menu_item_link href={~p"/research_consent"}> + Research Consent + + <.menu_divider /> + <% end %> + """ + end + + attr(:ctx, SessionContext, required: true) + def user_icon(assigns) do ~H""" <%= case @ctx do %> @@ -410,4 +428,36 @@ defmodule OliWeb.Components.Delivery.UserAccount do end defp to_initials(_), do: "?" + + defp show_research_consent_link?(user) do + case user do + nil -> + false + + # Direct delivery user + %User{independent_learner: true} -> + case Delivery.get_research_consent_form_setting() do + :oli_form -> + true + + _ -> + false + end + + # LTI user + user -> + # check institution research consent setting + institution = Institutions.get_institution_by_lti_user(user) + + case institution do + %Institution{research_consent: :oli_form} -> + true + + # if research consent is set to anything else or institution was + # not found for LTI user, do not show the link + _ -> + false + end + end + end end diff --git a/lib/oli_web/components/project/async_exporter.ex b/lib/oli_web/components/project/async_exporter.ex index b1b77b5a398..9aa85421d1c 100644 --- a/lib/oli_web/components/project/async_exporter.ex +++ b/lib/oli_web/components/project/async_exporter.ex @@ -173,4 +173,55 @@ defmodule OliWeb.Components.Project.AsyncExporter do """ end + + attr(:ctx, SessionContext, required: true) + attr(:project_export_status, :atom, values: [:not_available, :in_progress, :available, :error]) + attr(:project_export_url, :string) + attr(:project_export_timestamp, :string) + attr(:on_generate_project_export, :string, default: "generate_project_export") + + def project_export(assigns) do + ~H""" + <%= case @project_export_status do %> + <% status when status in [:not_available, :expired] -> %> + <.button variant={:link} class="!px-3" phx-click={@on_generate_project_export}> + Export + +
Download this project and its contents
+ <% :in_progress -> %> + <.button variant={:link} class="!px-3" disabled>Export in Progress +
+
Download this project and its contents
+
+ + Generating project export... this might take a while. +
+
+ <% :available -> %> + <.button variant={:link} class="!px-3" href={@project_export_url} download> + Download Latest Export + +
+
Download this project and its contents.
+
+ Created <%= date(@project_export_timestamp, @ctx) %>. + <.button variant={:link} phx-click={@on_generate_project_export}> + Regenerate + +
+
+ <% :error -> %> + <.button variant={:link} class="!px-3" phx-click={@on_generate_project_export}> + Export + +
+
Download this project and its contents
+
+ + Error generating project export. Please try again later or contact support. +
+
+ <% end %> + """ + end end diff --git a/lib/oli_web/controllers/delivery_controller.ex b/lib/oli_web/controllers/delivery_controller.ex index 177fe963a86..ea6f9ad8fa1 100644 --- a/lib/oli_web/controllers/delivery_controller.ex +++ b/lib/oli_web/controllers/delivery_controller.ex @@ -6,9 +6,11 @@ defmodule OliWeb.DeliveryController do alias Oli.Accounts alias Oli.Accounts.{User, Author} alias Oli.Analytics.DataTables.DataTable + alias Oli.Delivery alias Oli.Delivery.Sections alias Oli.Delivery.Sections.EnrollmentBrowseOptions alias Oli.Institutions + alias Oli.Institutions.Institution alias Oli.Lti.LtiParams alias Oli.Repo alias Oli.Repo.{Paging, Sorting} @@ -77,7 +79,7 @@ defmodule OliWeb.DeliveryController do ) if institution.research_consent != :no_form and is_nil(user.research_opt_out) do - render_research_consent(conn) + render_research_consent(conn, ~p"/sections/#{section.slug}") else redirect_to_page_delivery(conn, section) end @@ -92,12 +94,51 @@ defmodule OliWeb.DeliveryController do render(conn, "getting_started.html") end - defp render_research_consent(conn) do - conn - |> assign(:opt_out, nil) - |> render("research_consent.html") + defp render_research_consent(conn, redirect_url) do + case conn.assigns.current_user do + nil -> + conn + |> put_flash(:error, "User not found") + |> redirect(to: Routes.delivery_path(conn, :index)) + + # Direct delivery users + %User{independent_learner: true} = user -> + case Delivery.get_research_consent_form_setting() do + :oli_form -> + conn + |> assign(:research_opt_out, user_research_opt_out?(user)) + |> assign(:redirect_url, redirect_url) + |> render("research_consent.html") + + _ -> + conn + |> put_flash(:error, "Research consent is not enabled for this platform") + |> redirect(to: Routes.delivery_path(conn, :index)) + end + + # LTI users + user -> + # check institution research consent setting + institution = Institutions.get_institution_by_lti_user(user) + + case institution do + %Institution{research_consent: :oli_form} -> + conn + |> assign(:research_opt_out, user_research_opt_out?(user)) + |> assign(:redirect_url, redirect_url) + |> render("research_consent.html") + + _ -> + conn + |> put_flash(:error, "Research consent is not enabled for your institution") + |> redirect(to: Routes.delivery_path(conn, :index)) + end + end end + defp user_research_opt_out?(%User{research_opt_out: true}), do: true + defp user_research_opt_out?(_), do: false + defp redirect_to_page_delivery(conn, section) do redirect(conn, to: ~p"/sections/#{section.slug}" @@ -110,19 +151,38 @@ defmodule OliWeb.DeliveryController do ) end - def research_consent(conn, %{"consent" => consent}) do + def show_research_consent(conn, _params) do + user = conn.assigns.current_user + + redirect_url = + case user do + %User{independent_learner: true} -> + case Map.get(conn.assigns, :section) do + nil -> ~p"/sections" + section -> ~p"/sections/#{section.slug}" + end + + _ -> + ~p"/course" + end + + conn + |> assign(:research_opt_out, user.research_opt_out) + |> render_research_consent(redirect_url) + end + + def research_consent(conn, %{"consent" => consent, "redirect_url" => redirect_url}) do user = conn.assigns.current_user - lti_params = conn.assigns.lti_params - section = Sections.get_section_from_lti_params(lti_params) case Accounts.update_user(user, %{research_opt_out: consent !== "true"}) do {:ok, _} -> - redirect_to_page_delivery(conn, section) + conn + |> redirect(to: redirect_url) {:error, _} -> conn |> put_flash(:error, "Unable to persist research consent option") - |> redirect_to_page_delivery(section) + |> redirect(to: redirect_url) end end diff --git a/lib/oli_web/controllers/project_controller.ex b/lib/oli_web/controllers/project_controller.ex index d092142e8da..ca422b8228e 100644 --- a/lib/oli_web/controllers/project_controller.ex +++ b/lib/oli_web/controllers/project_controller.ex @@ -33,15 +33,6 @@ defmodule OliWeb.ProjectController do end end - def download_export(conn, _project_params) do - project = conn.assigns.project - - conn - |> send_download({:binary, Oli.Interop.Export.export(project)}, - filename: "export_#{project.slug}.zip" - ) - end - def clone_project(conn, _project_params) do case Clone.clone_project(conn.assigns.project.slug, conn.assigns.current_author) do {:ok, project} -> diff --git a/lib/oli_web/live/delivery/open_and_free_index.ex b/lib/oli_web/live/delivery/open_and_free_index.ex index 4ba83c6516a..e11bd6bb032 100644 --- a/lib/oli_web/live/delivery/open_and_free_index.ex +++ b/lib/oli_web/live/delivery/open_and_free_index.ex @@ -132,6 +132,7 @@ defmodule OliWeb.Delivery.OpenAndFreeIndex do :for={{section, index} <- Enum.with_index(@filtered_sections)} index={index} section={section} + params={@params} />

No course found matching "<%= @params.text_search %>" @@ -179,7 +180,7 @@ defmodule OliWeb.Delivery.OpenAndFreeIndex do

<.link :for={{section, index} <- Enum.with_index(@filtered_sections)} - href={get_course_url(section)} + href={get_course_url(section, @params.sidebar_expanded)} phx-click={JS.add_class("opacity-0", to: "#content")} phx-mounted={ JS.transition( @@ -240,6 +241,7 @@ defmodule OliWeb.Delivery.OpenAndFreeIndex do attr :section, :map attr :index, :integer + attr :params, :map def course_card(assigns) do ~H""" @@ -276,7 +278,7 @@ defmodule OliWeb.Delivery.OpenAndFreeIndex do
<.link - href={get_course_url(@section)} + href={get_course_url(@section, @params.sidebar_expanded)} class="px-5 py-3 bg-[#0080FF] hover:bg-[#0075EB] dark:bg-[#0062F2] dark:hover:bg-[#0D70FF] hover:no-underline rounded-md justify-center items-center gap-2 flex text-white text-base font-normal leading-normal" >
@@ -373,8 +375,11 @@ defmodule OliWeb.Delivery.OpenAndFreeIndex do end) end - defp get_course_url(%{user_role: "student", slug: slug}), do: ~p"/sections/#{slug}" - defp get_course_url(%{slug: slug}), do: ~p"/sections/#{slug}/instructor_dashboard/manage" + defp get_course_url(%{user_role: "student", slug: slug}, sidebar_expanded), + do: ~p"/sections/#{slug}?#{%{sidebar_expanded: sidebar_expanded}}" + + defp get_course_url(%{slug: slug}, sidebar_expanded), + do: ~p"/sections/#{slug}/instructor_dashboard/manage?#{%{sidebar_expanded: sidebar_expanded}}" defp decode_params(params) do %{ diff --git a/lib/oli_web/live/delivery/student/lesson_live.ex b/lib/oli_web/live/delivery/student/lesson_live.ex index 4a45bac1e2b..38638a2b351 100644 --- a/lib/oli_web/live/delivery/student/lesson_live.ex +++ b/lib/oli_web/live/delivery/student/lesson_live.ex @@ -89,6 +89,7 @@ defmodule OliWeb.Delivery.Student.LessonLive do effective_end_time: effective_end_time, auto_submit: page_context.effective_settings.late_submit == :disallow, time_limit: page_context.effective_settings.time_limit, + grace_period: page_context.effective_settings.grace_period, attempt_start_time: resource_attempt.inserted_at |> to_epoch, review_mode: page_context.review_mode ) @@ -747,6 +748,7 @@ defmodule OliWeb.Delivery.Student.LessonLive do data-time-out-in-mins={@time_limit} data-start-time-in-ms={@attempt_start_time} data-effective-time-in-ms={@effective_end_time} + data-grace-period-in-mins={@grace_period} data-auto-submit={if @auto_submit, do: "true", else: "false"} >
diff --git a/lib/oli_web/live/delivery/student/prologue_live.ex b/lib/oli_web/live/delivery/student/prologue_live.ex index 53b487608e9..f1dacbd8daa 100644 --- a/lib/oli_web/live/delivery/student/prologue_live.ex +++ b/lib/oli_web/live/delivery/student/prologue_live.ex @@ -214,15 +214,28 @@ defmodule OliWeb.Delivery.Student.PrologueLive do Attempt <%= @index %>:
+
+ Submitted +
+
+ +
-
-
+
+
<%= Float.round(@attempt.score, 2) %>
-
+
/
-
+
<%= Float.round(@attempt.out_of, 2) %>
diff --git a/lib/oli_web/live/features/features_live.ex b/lib/oli_web/live/features/features_live.ex index 708ff5ece08..752a78dc912 100644 --- a/lib/oli_web/live/features/features_live.ex +++ b/lib/oli_web/live/features/features_live.ex @@ -7,6 +7,7 @@ defmodule OliWeb.Features.FeaturesLive do alias OliWeb.Common.Breadcrumb alias Oli.Features + alias Oli.Delivery defp set_breadcrumbs() do OliWeb.Admin.AdminView.breadcrumb() ++ @@ -16,13 +17,16 @@ defmodule OliWeb.Features.FeaturesLive do end def mount(_, _, socket) do + research_consent_form_setting = Delivery.get_research_consent_form_setting() + {:ok, assign(socket, title: "Feature Flags", log_level: Logger.level(), active: :features, features: Features.list_features_and_states(), - breadcrumbs: set_breadcrumbs() + breadcrumbs: set_breadcrumbs(), + research_consent_form_setting: research_consent_form_setting )} end @@ -145,6 +149,28 @@ defmodule OliWeb.Features.FeaturesLive do
+ +
+

+ Research Consent +

+
+
+ <.form :let={f} for={%{}} phx-change="change_research_consent_form"> + + + <.input + field={f[:research_consent_form]} + type="select" + value={@research_consent_form_setting} + options={[{"OLI Form", :oli_form}, {"No Form", :no_form}]} + class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + > + + +
""" end @@ -169,4 +195,16 @@ defmodule OliWeb.Features.FeaturesLive do {:noreply, socket} end + + def handle_event( + "change_research_consent_form", + %{"research_consent_form" => research_consent_form}, + socket + ) do + research_consent_form_selection = String.to_existing_atom(research_consent_form) + + Delivery.update_research_consent_form_setting(research_consent_form_selection) + + {:noreply, assign(socket, research_consent_form_setting: research_consent_form_selection)} + end end diff --git a/lib/oli_web/live/projects/overview_live.ex b/lib/oli_web/live/projects/overview_live.ex index 1c6c7515d3e..f9bd2745473 100644 --- a/lib/oli_web/live/projects/overview_live.ex +++ b/lib/oli_web/live/projects/overview_live.ex @@ -9,14 +9,18 @@ defmodule OliWeb.Projects.OverviewLive do alias Oli.Authoring.Course.Project alias Oli.Inventories alias Oli.Publishing - alias OliWeb.Common.Breadcrumb alias Oli.Activities alias Oli.Publishing.AuthoringResolver alias Oli.Resources.Collaboration + alias Oli.Authoring.Broadcaster + alias Oli.Authoring.Broadcaster.Subscriber + alias Oli.Authoring.ProjectExportWorker + alias OliWeb.Common.Breadcrumb alias OliWeb.Components.Overview alias OliWeb.Projects.{RequiredSurvey, TransferPaymentCodes} alias OliWeb.Common.SessionContext alias OliWeb.Components.Common + alias OliWeb.Components.Project.AsyncExporter def mount(_params, session, socket) do ctx = SessionContext.init(socket, session) @@ -37,6 +41,15 @@ defmodule OliWeb.Projects.OverviewLive do |> Enum.map(fn {k, v} -> {v.text, k} end) |> Enum.sort(:desc) + {project_export_status, project_export_url, project_export_timestamp} = + case ProjectExportWorker.project_export_status(project) do + {:available, url, timestamp} -> {:available, url, timestamp} + {status} -> {status, nil, nil} + end + + # Subscribe to any project export progress updates for this project + Subscriber.subscribe_to_project_export_status(project.slug) + socket = assign(socket, ctx: ctx, @@ -56,7 +69,10 @@ defmodule OliWeb.Projects.OverviewLive do collab_space_config: collab_space_config, revision_slug: revision_slug, latest_publication: latest_publication, - notes_config: %{} + notes_config: %{}, + project_export_status: project_export_status, + project_export_url: project_export_url, + project_export_timestamp: project_export_timestamp ) {:ok, socket} @@ -390,12 +406,12 @@ defmodule OliWeb.Projects.OverviewLive do
- <%= button("Export", - to: Routes.project_path(@socket, :download_export, @project), - method: :post, - class: "btn btn-link action-button" - ) %> - Download this project and its contents. +
@@ -407,7 +423,7 @@ defmodule OliWeb.Projects.OverviewLive do <% _pub -> %> <.button - class="btn btn-link action-button" + class="btn btn-link action-button !px-3" href={~p"/project/#{@project.slug}/datashop"} > Datashop Analytics @@ -416,7 +432,7 @@ defmodule OliWeb.Projects.OverviewLive do <% end %>
-
+
<%= form_for @conn, Routes.delivery_path(@conn, :research_consent), fn _f -> %> -
-
- + +
+
+ +
+
+ +
-
- -
-
-
- <%= submit "Submit", id: "select-submit", class: "btn btn-primary" %> -
+
+ <%= submit "Submit", id: "select-submit", class: "btn btn-primary" %> +
<% end %> diff --git a/mix.exs b/mix.exs index 9d4aa703ad9..f85c527aa17 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Oli.MixProject do def project do [ app: :oli, - version: "0.28.0", + version: "0.29.0", elixir: "~> 1.15.5", elixirc_paths: elixirc_paths(Mix.env()), elixirc_options: elixirc_options(Mix.env()), diff --git a/priv/repo/migrations/20240709205804_async_project_export.exs b/priv/repo/migrations/20240709205804_async_project_export.exs new file mode 100644 index 00000000000..40f8e123bb6 --- /dev/null +++ b/priv/repo/migrations/20240709205804_async_project_export.exs @@ -0,0 +1,10 @@ +defmodule Oli.Repo.Migrations.AsyncProjectExport do + use Ecto.Migration + + def change do + alter table(:projects) do + add :latest_export_url, :string + add :latest_export_timestamp, :utc_datetime + end + end +end diff --git a/priv/repo/migrations/20240711170055_create_research_consent.exs b/priv/repo/migrations/20240711170055_create_research_consent.exs new file mode 100644 index 00000000000..423239c2a3f --- /dev/null +++ b/priv/repo/migrations/20240711170055_create_research_consent.exs @@ -0,0 +1,24 @@ +defmodule Oli.Repo.Migrations.CreateResearchConsent do + use Ecto.Migration + + import Ecto.Query, warn: false + + alias Oli.Repo + + def up do + create table(:research_consent) do + add :research_consent, :string, default: "oli_form", null: false + end + + flush() + + Repo.insert_all( + "research_consent", + [%{research_consent: "oli_form"}] + ) + end + + def down do + drop table(:research_consent) + end +end diff --git a/priv/repo/migrations/20240716140728_add_auto_update_sections.exs b/priv/repo/migrations/20240716140728_add_auto_update_sections.exs new file mode 100644 index 00000000000..4a95afb6bad --- /dev/null +++ b/priv/repo/migrations/20240716140728_add_auto_update_sections.exs @@ -0,0 +1,9 @@ +defmodule Oli.Repo.Migrations.AddAutoUpdateSections do + use Ecto.Migration + + def change do + alter table(:projects) do + add :auto_update_sections, :boolean, default: true + end + end +end diff --git a/priv/repo/migrations/20240719181530_enrollments_context_roles_unique_index.exs b/priv/repo/migrations/20240719181530_enrollments_context_roles_unique_index.exs new file mode 100644 index 00000000000..0cbcff3140c --- /dev/null +++ b/priv/repo/migrations/20240719181530_enrollments_context_roles_unique_index.exs @@ -0,0 +1,36 @@ +defmodule Oli.Repo.Migrations.EnrollmentsContextRolesUniqueIndex do + use Ecto.Migration + + def up do + # add temporary auto incrementing primary key column for duplicates removal operation + execute("ALTER TABLE enrollments_context_roles ADD COLUMN id SERIAL PRIMARY KEY") + + # delete duplicate enrollments_context_roles + execute(""" + DELETE FROM enrollments_context_roles + WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY enrollment_id, context_role_id ORDER BY id) AS rnum + FROM enrollments_context_roles + ) t + WHERE t.rnum > 1 + ) + """) + + # drop existing non-unique index + drop index(:enrollments_context_roles, [:enrollment_id, :context_role_id]) + + # create unique index + create unique_index(:enrollments_context_roles, [:enrollment_id, :context_role_id]) + + # drop temporary primary key column + execute("ALTER TABLE enrollments_context_roles DROP COLUMN id") + end + + def down do + # drop unique index + drop unique_index(:enrollments_context_roles, [:enrollment_id, :context_role_id]) + + create index(:enrollments_context_roles, [:enrollment_id, :context_role_id]) + end +end diff --git a/test/oli/analytics/xapi/pipeline_test.exs b/test/oli/analytics/xapi/pipeline_test.exs index faf731cfd79..b4beda10beb 100644 --- a/test/oli/analytics/xapi/pipeline_test.exs +++ b/test/oli/analytics/xapi/pipeline_test.exs @@ -59,7 +59,7 @@ defmodule Oli.Analytics.XAPI.PipelineTest do bundle2b = make_bundle("2", map.upload_directory) ref = Broadway.test_batch(UploadPipeline, [bundle1a, bundle1b, bundle2a, bundle2b]) - assert_receive {:ack, ^ref, success, failure}, 1000 + assert_receive {:ack, ^ref, success, failure}, 10_000 # Verify that the two common messages were handled each in separate batches assert length(success) == 2 diff --git a/test/oli_web/controllers/project_controller_test.exs b/test/oli_web/controllers/project_controller_test.exs index f7e2eb0b499..aa84809fdfd 100644 --- a/test/oli_web/controllers/project_controller_test.exs +++ b/test/oli_web/controllers/project_controller_test.exs @@ -64,7 +64,6 @@ defmodule OliWeb.ProjectControllerTest do setup [:admin_conn, :create_project_with_products] test "export a project with products works correctly", %{ - conn: conn, project: project, product_1: product_1, product_2: product_2, @@ -86,14 +85,10 @@ defmodule OliWeb.ProjectControllerTest do assert Map.has_key?(m, ~c"#{product_2.id}.json") assert Map.has_key?(m, ~c"#{page_resource_1.id}.json") assert Map.has_key?(m, ~c"#{page_resource_2.id}.json") - - conn = post(conn, Routes.project_path(conn, :download_export, project.id)) - assert html_response(conn, 302) =~ "/authoring/projects" end test "export a project with products works correctly by filtering out products that have publications from other projects.", %{ - conn: conn, project: project, product_1: product_1, product_2: product_2, @@ -116,9 +111,6 @@ defmodule OliWeb.ProjectControllerTest do assert Map.has_key?(m, ~c"#{product_1.id}.json") assert Map.has_key?(m, ~c"#{page_resource_1.id}.json") assert Map.has_key?(m, ~c"#{page_resource_2.id}.json") - - conn = post(conn, Routes.project_path(conn, :download_export, project.id)) - assert html_response(conn, 302) =~ "/authoring/projects" end end diff --git a/test/oli_web/live/delivery/open_and_free_index_test.exs b/test/oli_web/live/delivery/open_and_free_index_test.exs index e6188b21114..de74f79fd78 100644 --- a/test/oli_web/live/delivery/open_and_free_index_test.exs +++ b/test/oli_web/live/delivery/open_and_free_index_test.exs @@ -80,13 +80,14 @@ defmodule OliWeb.Delivery.OpenAndFreeIndexTest do Sections.enroll(user.id, section.id, [ContextRoles.get_role(:context_learner)]) - {:ok, view, _html} = live(conn, ~p"/sections?active_workspace=student_workspace") + {:ok, view, _html} = + live(conn, ~p"/sections?active_workspace=student_workspace&sidebar_expanded=true") assert render(view) =~ ~s|style=\"background-image: url('https://example.com/some-image-url.png');\"| assert has_element?(view, "h5", "The best course ever!") - assert has_element?(view, ~s{a[href="/sections/#{section.slug}"]}) + assert has_element?(view, ~s{a[href="/sections/#{section.slug}?sidebar_expanded=true"]}) end test "if no cover image is set, renders default image in enrollment page in the student workspace", @@ -224,7 +225,8 @@ defmodule OliWeb.Delivery.OpenAndFreeIndexTest do Sections.enroll(user.id, section.id, [ContextRoles.get_role(:context_instructor)]) - {:ok, view, _html} = live(conn, ~p"/sections?active_workspace=instructor_workspace") + {:ok, view, _html} = + live(conn, ~p"/sections?active_workspace=instructor_workspace&sidebar_expanded=true") assert render(view) =~ ~s|style=\"background-image: url('https://example.com/some-image-url.png');\"| @@ -233,7 +235,7 @@ defmodule OliWeb.Delivery.OpenAndFreeIndexTest do assert has_element?( view, - ~s{a[href="/sections/#{section.slug}/instructor_dashboard/manage"]} + ~s{a[href="/sections/#{section.slug}/instructor_dashboard/manage?sidebar_expanded=true"]} ) end diff --git a/test/oli_web/live/delivery/student/learn_live_test.exs b/test/oli_web/live/delivery/student/learn_live_test.exs index 1c186ce9f78..fbee27454ef 100644 --- a/test/oli_web/live/delivery/student/learn_live_test.exs +++ b/test/oli_web/live/delivery/student/learn_live_test.exs @@ -2737,7 +2737,7 @@ defmodule OliWeb.Delivery.Student.ContentLiveTest do assert_redirect(view, "/sections/#{section.slug}/assignments?sidebar_expanded=false") end - test "exit course button redirects to sections view", %{ + test "exit course button redirects to sections view with the student workspace selected", %{ conn: conn, section: section } do @@ -2748,7 +2748,7 @@ defmodule OliWeb.Delivery.Student.ContentLiveTest do |> element(~s{nav[id=desktop-nav-menu] a[id="exit_course_button"]}, "Exit Course") |> render_click() - assert_redirect(view, "/sections") + assert_redirect(view, "/sections?active_workspace=student_workspace&sidebar_expanded=true") end test "logo icon redirects to home page", %{ diff --git a/test/oli_web/live/delivery/student/prologue_live_test.exs b/test/oli_web/live/delivery/student/prologue_live_test.exs index 48df9aa80f1..bb83c0ab8c9 100644 --- a/test/oli_web/live/delivery/student/prologue_live_test.exs +++ b/test/oli_web/live/delivery/student/prologue_live_test.exs @@ -630,7 +630,8 @@ defmodule OliWeb.Delivery.Student.PrologueLiveTest do date_submitted: ~U[2023-11-15 20:00:00Z], date_evaluated: ~U[2023-11-15 20:10:00Z], score: 10, - out_of: 10 + out_of: 10, + lifecycle_state: :evaluated }) {:ok, view, _html} = live(conn, Utils.prologue_live_path(section.slug, page_3.slug)) @@ -640,20 +641,8 @@ defmodule OliWeb.Delivery.Student.PrologueLiveTest do assert has_element?( view, - "div[id='attempt_1_summary'] div[role='attempt score']", - "5.0" - ) - - assert has_element?( - view, - "div[id='attempt_1_summary'] div[role='attempt out of']", - "10.0" - ) - - assert has_element?( - view, - "div[id='attempt_1_summary'] div[role='attempt submission']", - "Tue Nov 14, 2023" + "div[id='attempt_1_summary'] div[role='attempt status']", + "Submitted" ) assert has_element?( diff --git a/test/oli_web/live/publish_live_test.exs b/test/oli_web/live/publish_live_test.exs index 557013d4723..0e5ea24fcd4 100644 --- a/test/oli_web/live/publish_live_test.exs +++ b/test/oli_web/live/publish_live_test.exs @@ -635,6 +635,10 @@ defmodule OliWeb.PublishLiveTest do |> element("form[phx-submit=\"publish_active\"") |> render_submit(%{description: "New description"}) + view + |> element("button", "Ok") + |> render_click() + flash = assert_redirected(view, live_view_publish_route(project.slug)) assert flash["info"] == "Publish Successful!" end @@ -657,6 +661,10 @@ defmodule OliWeb.PublishLiveTest do |> element("form[phx-submit=\"publish_active\"") |> render_submit(%{description: "New description"}) + view + |> element("button", "Ok") + |> render_click() + # Two jobs are enqueued (only page revisions are considered, the objective revision is ignored) assert_enqueued( worker: Oli.Search.EmbeddingWorker, @@ -691,6 +699,10 @@ defmodule OliWeb.PublishLiveTest do |> element("form[phx-submit=\"publish_active\"") |> render_submit(%{description: "New description"}) + view + |> element("button", "Ok") + |> render_click() + # No jobs are enqueued refute_enqueued( worker: Oli.Search.EmbeddingWorker,