From 66352919423c04f95217c25a67951973ca4c8331 Mon Sep 17 00:00:00 2001 From: Cay Henning Date: Tue, 14 Nov 2023 16:01:23 -0500 Subject: [PATCH 01/32] Separates error handling from success functions, updates error responses in API, adds global error handler --- fuel/app/classes/controller/admin.php | 6 +- fuel/app/classes/controller/api.php | 53 +- fuel/app/classes/controller/api/admin.php | 2 +- fuel/app/classes/controller/api/asset.php | 4 +- fuel/app/classes/controller/api/instance.php | 16 +- fuel/app/classes/materia/api/v1.php | 74 ++- fuel/app/classes/materia/msg.php | 30 +- fuel/app/classes/materia/perm/manager.php | 2 +- fuel/app/tests/api/v1.php | 47 +- fuel/app/tests/controller/api/instance.php | 21 +- package.json | 1 + src/components/detail-carousel.jsx | 16 +- src/components/extra-attempts-dialog.jsx | 4 +- src/components/header.jsx | 12 +- src/components/hooks/useCopyWidget.jsx | 55 +- src/components/hooks/useCreatePlaySession.jsx | 15 +- .../hooks/useDeleteNotification.jsx | 3 +- src/components/hooks/useDeleteWidget.jsx | 20 +- src/components/hooks/useInstanceList.jsx | 2 +- src/components/hooks/usePlayLogSave.jsx | 12 +- .../hooks/usePlayStorageDataSave.jsx | 7 +- src/components/hooks/useSetAttempts.jsx | 7 +- .../hooks/useSetUserInstancePerms.jsx | 4 +- src/components/hooks/useSupportCopyWidget.jsx | 4 + .../hooks/useSupportDeleteWidget.jsx | 14 +- .../hooks/useSupportUnDeleteWidget.jsx | 18 +- .../hooks/useSupportUpdateWidget.jsx | 7 +- src/components/hooks/useUpdateUserRoles.jsx | 6 +- .../hooks/useUpdateUserSettings.jsx | 8 +- src/components/hooks/useUpdateWidget.jsx | 5 +- src/components/include.scss | 104 ++- src/components/lti/open-preview.jsx | 6 +- src/components/media-importer.jsx | 8 +- .../my-widgets-collaborate-dialog.jsx | 72 +- .../my-widgets-collaborate-dialog.scss | 9 +- src/components/my-widgets-copy-dialog.jsx | 43 +- src/components/my-widgets-copy-dialog.scss | 3 +- src/components/my-widgets-page.jsx | 124 ++-- src/components/my-widgets-scores.jsx | 20 +- .../my-widgets-selected-instance.jsx | 43 +- src/components/my-widgets-settings-dialog.jsx | 43 +- .../my-widgets-settings-dialog.scss | 4 +- src/components/notifications.jsx | 57 +- src/components/pre-embed-common-styles.scss | 16 +- src/components/profile-page.jsx | 51 +- src/components/question-history.jsx | 31 +- src/components/question-importer.jsx | 2 +- src/components/scores.jsx | 43 +- src/components/settings-page.jsx | 74 ++- src/components/support-page.scss | 24 +- src/components/support-selected-instance.jsx | 506 +++++++------- .../user-admin-instance-available.jsx | 26 +- src/components/user-admin-page.scss | 12 - src/components/user-admin-role-manager.jsx | 6 + src/components/widget-admin-install.jsx | 18 +- src/components/widget-admin-list-card.jsx | 28 +- src/components/widget-creator.jsx | 123 ++-- src/components/widget-player.jsx | 129 ++-- src/my-widgets.js | 6 +- src/util/api.js | 628 +++++++----------- src/util/fetch-options.js | 4 +- src/util/global-cache-options.js | 8 + yarn.lock | 12 + 63 files changed, 1561 insertions(+), 1197 deletions(-) create mode 100644 src/util/global-cache-options.js diff --git a/fuel/app/classes/controller/admin.php b/fuel/app/classes/controller/admin.php index aabac218e..1684a1045 100644 --- a/fuel/app/classes/controller/admin.php +++ b/fuel/app/classes/controller/admin.php @@ -20,7 +20,7 @@ public function before() public function get_widget() { if ( ! \Materia\Perm_Manager::is_super_user() ) throw new \HttpNotFoundException; - + Js::push_inline('var UPLOAD_ENABLED ="'.Config::get('materia.enable_admin_uploader').'";'); Js::push_inline('var HEROKU_WARNING ="'.Config::get('materia.heroku_admin_warning').'";'); Js::push_inline('var ACTION_LINK ="/admin/upload";'); @@ -78,8 +78,8 @@ public function post_upload() } } } - - if ($failed) + + if ($failed) { throw new HttpServerErrorException; } diff --git a/fuel/app/classes/controller/api.php b/fuel/app/classes/controller/api.php index 4d6bd31ae..484f1c81a 100644 --- a/fuel/app/classes/controller/api.php +++ b/fuel/app/classes/controller/api.php @@ -43,14 +43,48 @@ public function before() parent::before(); } + /** + * Recursively search for the status code in execution result + * @param Array + * @return Integer + */ + public function get_status($data) + { + if (is_array($data) || is_object($data)) + { + foreach ($data as $key => $value) + { + if ($key === 'status') + { + return $value; + } + elseif (is_array($key) || is_object($key)) + { + $result = $this->get_status($key); + if ($result !== null) + { + return $result; + } + } + } + } + } + public function post_call($version, $format, $method) { $input = json_decode(Input::post('data', [])); $result = $this->execute($version, $method, $input); + $status = $this->get_status($result); + + if ( ! $status) + { + $status = 200; + } + $this->no_cache(); - $this->response($result, 200); + $this->response($result, $status); } public function get_call($version, $format, $method) @@ -58,8 +92,15 @@ public function get_call($version, $format, $method) $data = array_slice($this->request->route->method_params, 3); $result = $this->execute($version, $method, $data); + $status = $this->get_status($result); + + if ( ! $status) + { + $status = 200; + } + $this->no_cache(); - $this->response($result, 200); + $this->response($result, $status); } protected function execute($version, $method, $args) @@ -77,6 +118,14 @@ protected function execute($version, $method, $args) { Materia\Log::profile([get_class($e), get_class($api), $method, json_encode($args)], 'exception'); trace($e); + if ($e instanceof \HttpNotFoundException) + { + return Materia\Msg::not_found(); + } + else + { + throw new HttpServerErrorException; + } } } } diff --git a/fuel/app/classes/controller/api/admin.php b/fuel/app/classes/controller/api/admin.php index df6e1bb2b..bfe0f40c9 100644 --- a/fuel/app/classes/controller/api/admin.php +++ b/fuel/app/classes/controller/api/admin.php @@ -118,7 +118,7 @@ public function post_widget_instance_undelete(string $inst_id) { if ( ! \Materia\Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); if (\Service_User::verify_session() !== true) return Msg::no_login(); - if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id, false, false, true))) return new Msg(Msg::ERROR, 'Widget instance does not exist.'); + if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id, false, false, true))) return Msg::failure('Widget instance does not exist.'); return $inst->db_undelete(); } } diff --git a/fuel/app/classes/controller/api/asset.php b/fuel/app/classes/controller/api/asset.php index 45fc8afa9..77926a556 100644 --- a/fuel/app/classes/controller/api/asset.php +++ b/fuel/app/classes/controller/api/asset.php @@ -34,7 +34,7 @@ public function post_delete($asset_id) \Log::error('Error: In the deletion process'); \Log::error($th); - return new Msg(Msg::ERROR, 'Asset could not be deleted.'); + return Msg::failure('Asset could not be deleted.'); } } @@ -61,7 +61,7 @@ public function post_restore($asset_id) \Log::error('Error: In the deletion process'); \Log::error($th); - return new Msg(Msg::ERROR, 'Asset could not be restored.'); + return Msg::failure('Asset could not be restored.'); } } } \ No newline at end of file diff --git a/fuel/app/classes/controller/api/instance.php b/fuel/app/classes/controller/api/instance.php index ac2c9b09f..b0b60b402 100644 --- a/fuel/app/classes/controller/api/instance.php +++ b/fuel/app/classes/controller/api/instance.php @@ -19,9 +19,9 @@ class Controller_Api_Instance extends Controller_Rest */ public function get_history() { - if ( ! $inst_id = Input::get('inst_id')) return $this->response('Requires an inst_id parameter!', 401); + if ( ! $inst_id = Input::get('inst_id')) return $this->response(new \Materia\Msg('Requires an inst_id parameter!', \Materia\Msg::ERROR), 401); if ( ! \Materia\Util_Validator::is_valid_hash($inst_id) ) return $this->response(\Materia\Msg::invalid_input($inst_id), 401); - if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response('Instance not found', 404); + if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response(new \Materia\Msg('Instance not found', \Materia\Msg::ERROR), 404); if ( ! \Materia\Perm_Manager::user_has_any_perm_to(\Model_User::find_current_id(), $inst_id, \Materia\Perm::INSTANCE, [\Materia\Perm::FULL])) return $this->response(\Materia\Msg::no_login(), 401); $history = $inst->get_qset_history($inst_id); @@ -36,13 +36,15 @@ public function post_request_access() $inst_id = Input::json('inst_id', null); $owner_id = Input::json('owner_id', null); - if ( ! $inst_id) return $this->response('Requires an inst_id parameter', 401); - if ( ! $owner_id) return $this->response('Requires an owner_id parameter', 401); + if ( ! $inst_id) return $this->response(new \Materia\Msg('Requires an inst_id parameter', \Materia\Msg::ERROR), 401); - if ( ! \Model_User::find_by_id($owner_id)) return $this->response('Owner not found', 404); - if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response('Instance not found', 404); + if ( ! $owner_id) return $this->response(new \Materia\Msg('Requires an owner_id parameter', \Materia\Msg::ERROR), 401); - if ( ! Materia\Perm_Manager::user_has_any_perm_to($owner_id, $inst_id, Materia\Perm::INSTANCE, [Materia\Perm::FULL, Materia\Perm::VISIBLE])) return $this->response('Owner does not own instance', 404); + if ( ! \Model_User::find_by_id($owner_id)) return $this->response(new \Materia\Msg('Owner not found', \Materia\Msg::ERROR), 404); + + if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response(new \Materia\Msg('Instance not found', \Materia\Msg::ERROR), 404); + + if ( ! Materia\Perm_Manager::user_has_any_perm_to($owner_id, $inst_id, Materia\Perm::INSTANCE, [Materia\Perm::FULL, Materia\Perm::VISIBLE])) return $this->response(new \Materia\Msg('Owner does not own instance', \Materia\Msg::ERROR), 404); if ( ! \Materia\Util_Validator::is_valid_hash($inst_id) ) return $this->response(\Materia\Msg::invalid_input($inst_id), 401); diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index 509e66ab8..c199d2fe5 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -47,7 +47,7 @@ static public function widget_instances_get($inst_ids = null, bool $deleted = fa // get all my instances - must be logged in if (empty($inst_ids)) { - if (\Service_User::verify_session() !== true) return []; // shortcut to returning noting + if (\Service_User::verify_session() !== true) return Msg::no_login(); // shortcut to returning noting return Widget_Instance_Manager::get_all_for_user(\Model_User::find_current_id(), $load_qset); } @@ -79,38 +79,50 @@ static public function widget_instance_delete($inst_id) if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); if (\Service_User::verify_session() !== true) return Msg::no_login(); if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL]) && ! Perm_Manager::is_support_user()) return Msg::no_perm(); - if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) return false; - return $inst->db_remove(); + if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) throw new \HttpNotFoundException; + + $result = $inst->db_remove(); + if ($result) + { + return $inst_id; + } + else + { + return Msg::failure('Failed to remove widget instance from database'); + } } static public function widget_instance_access_perms_verify($inst_id) { if (\Service_User::verify_session() !== true) return Msg::no_login(); - return static::has_perms_to_inst($inst_id, [Perm::VISIBLE, Perm::FULL]); + + if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); + + if ( ! static::has_perms_to_inst($inst_id, [Perm::VISIBLE, Perm::FULL])) + { + return Msg::no_perm(); + } + return true; } /** * @return object, contains properties indicating whether the current * user can edit the widget and a message object describing why, if not */ - static public function widget_instance_edit_perms_verify(string $inst_id): \stdClass + static public function widget_instance_edit_perms_verify(string $inst_id) { $response = new \stdClass(); - $response->msg = null; $response->is_locked = true; $response->can_publish = false; - if ( ! Util_Validator::is_valid_hash($inst_id)) $response->msg = Msg::invalid_input($inst_id); - else if (\Service_User::verify_session() !== true) $response->msg = Msg::no_login(); - else if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL])) $response->msg = Msg::no_perm(); - else if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) $response->msg = new Msg(Msg::ERROR, 'Widget instance does not exist.'); + if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); + else if (\Service_User::verify_session() !== true) return Msg::no_login(); + else if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL])) return Msg::no_perm(); + else if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) throw new \HttpNotFoundException; //msg property only set if something went wrong - if ( ! $response->msg) - { - $response->is_locked = ! Widget_Instance_Manager::locked_by_current_user($inst_id); - $response->can_publish = $inst->widget->publishable_by(\Model_User::find_current_id()); - } + $response->is_locked = ! Widget_Instance_Manager::locked_by_current_user($inst_id); + $response->can_publish = $inst->widget->publishable_by(\Model_User::find_current_id()); return $response; } @@ -150,7 +162,7 @@ static public function widget_instance_copy(string $inst_id, string $new_name, b } catch (\Exception $e) { - return new Msg(Msg::ERROR, 'Widget instance could not be copied.'); + return Msg::failure('Widget instance could not be copied.'); } } @@ -177,8 +189,8 @@ static public function widget_instance_new($widget_id=null, $name=null, $qset=nu $widget = new Widget(); if ( $widget->get($widget_id) == false) return Msg::invalid_input('Invalid widget type'); - if ( ! $is_draft && ! $widget->publishable_by(\Model_User::find_current_id()) ) return new Msg(Msg::ERROR, 'Widget type can not be published by students.'); - if ( $is_draft && ! $widget->is_editable) return new Msg(Msg::ERROR, 'Non-editable widgets can not be saved as drafts!'); + if ( ! $is_draft && ! $widget->publishable_by(\Model_User::find_current_id()) ) return Msg::no_perm('Widget type can not be published by students.'); + if ( $is_draft && ! $widget->is_editable) return Msg::failure('Non-editable widgets can not be saved as drafts!'); $is_student = ! \Service_User::verify_session(['basic_author', 'super_user']); $inst = new Widget_Instance([ @@ -203,7 +215,7 @@ static public function widget_instance_new($widget_id=null, $name=null, $qset=nu catch (\Exception $e) { trace($e); - return new Msg(Msg::ERROR, 'Widget instance could not be saved.'); + return Msg::failure('Widget instance could not be saved.'); } } @@ -226,13 +238,13 @@ static public function widget_instance_update($inst_id=null, $name=null, $qset=n { if (\Service_User::verify_session() !== true) return Msg::no_login(); if (\Service_User::verify_session('no_author')) return Msg::invalid_input('You are not able to create or edit widgets.'); - if ( ! Util_Validator::is_valid_hash($inst_id)) return new Msg(Msg::ERROR, 'Instance id is invalid'); + if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input('Instance id is invalid'); if ( ! static::has_perms_to_inst($inst_id, [Perm::VISIBLE, Perm::FULL])) return Msg::no_perm(); $inst = Widget_Instance_Manager::get($inst_id, true); - if ( ! $inst) return new Msg(Msg::ERROR, 'Widget instance could not be found.'); - if ( $is_draft && ! $inst->widget->is_editable) return new Msg(Msg::ERROR, 'Non-editable widgets can not be saved as drafts!'); - if ( ! $is_draft && ! $inst->widget->publishable_by(\Model_User::find_current_id())) return new Msg(Msg::ERROR, 'Widget type can not be published by students.'); + if ( ! $inst) return Msg::failure('Widget instance could not be found.'); + if ( $is_draft && ! $inst->widget->is_editable) return Msg::failure('Non-editable widgets can not be saved as drafts!'); + if ( ! $is_draft && ! $inst->widget->publishable_by(\Model_User::find_current_id())) return Msg::no_perm('Widget type can not be published by students.'); // student made widgets are locked forever if ($inst->is_student_made) @@ -378,7 +390,7 @@ static public function widget_instance_update($inst_id=null, $name=null, $qset=n } catch (\Exception $e) { - return new Msg(Msg::ERROR, 'Widget could not be created.'); + return Msg::failure('Widget could not be created.'); } } @@ -397,7 +409,7 @@ static public function session_play_create($inst_id, $context_id=false) { if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) throw new \HttpNotFoundException; if ( ! $inst->playable_by_current_user()) return Msg::no_login(); - if ( $inst->is_draft == true) return new Msg(Msg::ERROR, 'Drafts Not Playable', 'Must use Preview to play a draft.'); + if ( $inst->is_draft == true) return Msg::failure('Drafts Not Playable', 'Must use Preview to play a draft.'); $play = new Session_Play(); $play_id = $play->start(\Model_User::find_current_id(), $inst_id, $context_id); @@ -433,7 +445,7 @@ static public function session_author_verify($role_name = null) static public function session_play_verify($play_id) { // Standard session validation first - if (\Service_User::verify_session() !== true) return false; + if (\Service_User::verify_session() !== true) return Msg::no_login(); // if $play_id is null, assume it's a preview, no need for user check if ( ! $play_id) return true; @@ -481,7 +493,7 @@ static public function play_logs_save($play_id, $logs, $preview_inst_id = null) else { // No user in session, just perform auth check - if (\Service_User::verify_session() !== true) return false; + if (\Service_User::verify_session() !== true) return Msg::no_login(); } if ( $preview_inst_id === null && ! Util_Validator::is_valid_long_hash($play_id)) return Msg::invalid_input($play_id); @@ -524,7 +536,7 @@ static public function play_logs_save($play_id, $logs, $preview_inst_id = null) if ($score_mod->validate_times() == false) { $play->invalidate(); - return new Msg(Msg::ERROR, 'Timing validation error.', true); + return Msg::failure('Timing validation error.'); } // if widget is not scorable, check for a participation score log @@ -548,7 +560,7 @@ static public function play_logs_save($play_id, $logs, $preview_inst_id = null) catch (Score_Exception $e) { $play->invalidate(); - return new Msg($e->message, $e->title, Msg::ERROR, true); + return Msg::failure($e->message, $e->title); } $return = []; @@ -743,7 +755,7 @@ static public function score_raw_distribution_get($inst_id, $get_all = false) } catch (\Exception $e) { trace("Error loading score module for {$inst_id}"); - return false; + return Msg::failure("Error loading score module for {$inst_id}"); } $result = null; @@ -1155,7 +1167,7 @@ static public function notification_delete($note_id, $delete_all) return true; } } - return false; + return Msg::failure('Failed to delete notification'); } /** * Returns all of the semesters from the semester table diff --git a/fuel/app/classes/materia/msg.php b/fuel/app/classes/materia/msg.php index 2ef6a4a54..f5f8827bb 100644 --- a/fuel/app/classes/materia/msg.php +++ b/fuel/app/classes/materia/msg.php @@ -43,30 +43,46 @@ public function __construct($msg, $title='', $type='error', $halt=false) $this->halt = $halt; } - static public function invalid_input($msg='') + static public function invalid_input($msg = '', $title = 'Validation Error') { - return new Msg($msg, 'Validation Error', Msg::ERROR, true); + $msg = new Msg($msg, $title, Msg::ERROR, true); + return new \Response(json_encode($msg), 403); } static public function no_login() { $msg = new Msg('You have been logged out, and must login again to continue', 'Invalid Login', Msg::ERROR, true); \Session::set_flash('login_error', $msg->msg); - return $msg; + return new \Response(json_encode($msg), 403); } - static public function no_perm() + static public function no_perm($msg = 'You do not have permission to access the requested content', $title = 'Permission Denied') { - return new Msg('You do not have permission to access the requested content', 'Permission Denied', Msg::WARN); + $msg = new Msg($msg, $title, Msg::WARN); + return new \Response(json_encode($msg), 401); } static public function student_collab() { - return new Msg('Students cannot be added as collaborators to widgets that have guest access disabled.', 'Share Not Allowed', Msg::ERROR); + $msg = new Msg('Students cannot be added as collaborators to widgets that have guest access disabled.', 'Share Not Allowed', Msg::ERROR); + return new \Response(json_encode($msg), 401); } static public function student() { - return new Msg('Students are unable to receive notifications via Materia', 'No Notifications', Msg::NOTICE); + $msg = new Msg('Students are unable to receive notifications via Materia', 'No Notifications', Msg::NOTICE); + return new \Response(json_encode($msg), 403); + } + + static public function failure($msg = 'The requested action could not be completed', $title = 'Action Failed') + { + $msg = new Msg($msg, $title, Msg::ERROR); + return new \Response(json_encode($msg), 403); + } + + static public function not_found($msg = 'The requested content could not be found', $title = 'Not Found') + { + $msg = new Msg($msg, $title, Msg::ERROR); + return new \Response(json_encode($msg), 404); } } diff --git a/fuel/app/classes/materia/perm/manager.php b/fuel/app/classes/materia/perm/manager.php index bf3e703da..9986c66e0 100644 --- a/fuel/app/classes/materia/perm/manager.php +++ b/fuel/app/classes/materia/perm/manager.php @@ -38,7 +38,7 @@ static public function is_super_user() // The session caching has been removed due to issues related to the cache when the role is added or revoked // Ideally we can still find a way to cache this and make it more performant!! return (\Fuel::$is_cli === true && ! \Fuel::$is_test) || self::does_user_have_role([\Materia\Perm_Role::SU]); - + } /** diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index 42b524c6d..68cc29626 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -99,7 +99,7 @@ public function test_widgets_get_by_type() public function test_widget_instance_access_perms_verify() { - $output = Api_V1::widget_instance_access_perms_verify('test'); + $output = Api_V1::widget_instance_access_perms_verify('you_do_not_exist'); $this->assert_invalid_login_message($output); } @@ -250,8 +250,9 @@ public function test_widget_instance_new() $qset = $this->create_new_qset($question, $answer); $output = Api_V1::widget_instance_new($widget->id, 'test', $qset, false); - $this->assertInstanceOf('\Materia\Msg', $output); - $this->assertEquals('Widget type can not be published by students.', $output->title); + $this->assertInstanceOf('\Response', $output); + $body = json_decode($output->body); + $this->assertEquals('Widget type can not be published by students.', $body->msg); } public function test_widget_instance_update() @@ -621,24 +622,21 @@ public function test_widget_instance_edit_perms_verify(): void // ======= AS NO ONE ======== \Auth::logout(); $output = Api_V1::widget_instance_edit_perms_verify($instance->id); - $this->assertInstanceOf('\Materia\Msg', $output->msg); - $this->assertEquals('Invalid Login', $output->msg->title); - $this->assertTrue($output->is_locked); - $this->assertFalse($output->can_publish); + $this->assertInstanceOf('\Response', $output); + $body = json_decode($output->body); + $this->assertEquals('Invalid Login', $body->title); // ======= STUDENT ======== $this->_as_student(); $output = Api_V1::widget_instance_edit_perms_verify($instance->id); $this->assertFalse($output->is_locked); $this->assertFalse($output->can_publish); - $this->assertNull($output->msg); // ======= AUTHOR ======== $this->_as_author(); $output = Api_V1::widget_instance_edit_perms_verify($instance->id); $this->assertFalse($output->is_locked); $this->assertTrue($output->can_publish); - $this->assertNull($output->msg); // lock widget as author Api_V1::widget_instance_lock($instance->id); @@ -649,14 +647,12 @@ public function test_widget_instance_edit_perms_verify(): void $output = Api_V1::widget_instance_edit_perms_verify($instance->id); $this->assertTrue($output->is_locked); $this->assertFalse($output->can_publish); - $this->assertNull($output->msg); // ======= AUTHOR ======== $this->_as_author(); $output = Api_V1::widget_instance_edit_perms_verify($instance->id); $this->assertFalse($output->is_locked); $this->assertTrue($output->can_publish); - $this->assertNull($output->msg); } public function test_widget_publish_perms_verify(): void @@ -667,8 +663,9 @@ public function test_widget_publish_perms_verify(): void // ======= AS NO ONE ======== \Auth::logout(); $output = Api_V1::widget_publish_perms_verify($widget->id); - $this->assertInstanceOf('\Materia\Msg', $output); - $this->assertEquals('Invalid Login', $output->title); + $this->assertInstanceOf('\Response', $output); + $body = json_decode($output->body); + $this->assertEquals('Invalid Login', $body->title); // ======= STUDENT ======== $this->_as_student(); @@ -771,8 +768,9 @@ public function test_session_play_create() // this should fail - you cant play drafts $output = Api_V1::session_play_create($saveOutput->id); - $this->assertInstanceOf('\Materia\Msg', $output); - $this->assertEquals('Drafts Not Playable', $output->title); + $this->assertInstanceOf('\Response', $output); + $body = json_decode($output->body); + $this->assertEquals('Drafts Not Playable', $body->title); Api_V1::widget_instance_delete($saveOutput->id); @@ -1441,7 +1439,7 @@ public function test_notification_delete(){ // ======= STUDENT ======== $this->_as_student(); $output = Api_V1::notification_delete(5, false); - $this->assertFalse($output); + $this->assertInstanceOf('\Response', $output); $author = $this->_as_author(); $notifications = Api_V1::notifications_get(); @@ -1468,7 +1466,7 @@ public function test_notification_delete(){ // try as someone author2 $this->_as_author_2(); $output = Api_V1::notification_delete($notifications[0]['id'], false); - $this->assertFalse($output); + $this->assertInstanceOf('\Response', $output); $this->_as_author(); $output = Api_V1::notification_delete($notifications[0]['id'], false); @@ -1592,19 +1590,22 @@ protected function assert_not_message($result) protected function assert_invalid_login_message($msg) { - $this->assertInstanceOf('\Materia\Msg', $msg); - $this->assertEquals('Invalid Login', $msg->title); + $this->assertInstanceOf('\Response', $msg); + $body = json_decode($msg->body); + $this->assertEquals('Invalid Login', $body->title); } protected function assert_permission_denied_message($msg) { - $this->assertInstanceOf('\Materia\Msg', $msg); - $this->assertEquals('Permission Denied', $msg->title); + $this->assertInstanceOf('\Response', $msg); + $body = json_decode($msg->body); + $this->assertEquals('Permission Denied', $body->title); } protected function assert_validation_error_message($msg) { - $this->assertInstanceOf('\Materia\Msg', $msg); - $this->assertEquals('Validation Error', $msg->title); + $this->assertInstanceOf('\Response', $msg); + $body = json_decode($msg->body); + $this->assertEquals('Validation Error', $body->title); } } diff --git a/fuel/app/tests/controller/api/instance.php b/fuel/app/tests/controller/api/instance.php index 2bcf7e8b3..4a17fc802 100644 --- a/fuel/app/tests/controller/api/instance.php +++ b/fuel/app/tests/controller/api/instance.php @@ -21,7 +21,8 @@ public function test_get_history() ->response(); $this->assertEquals($response->status, 401); - $this->assertEquals($response->body, '"Requires an inst_id parameter!"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Requires an inst_id parameter!'); // ======= NO INST ID FOUND ======== $response = Request::forge('/api/instance/history') @@ -31,7 +32,8 @@ public function test_get_history() ->response(); $this->assertEquals($response->status, 404); - $this->assertEquals($response->body, '"Instance not found"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Instance not found'); // == Now we're an author $this->_as_author(); @@ -69,7 +71,8 @@ public function test_post_request_access() ->response(); $this->assertEquals($response->status, 401); - $this->assertEquals($response->body, '"Requires an inst_id parameter"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Requires an inst_id parameter'); // ======= NO OWNER ID PROVIDED ======== $response = Request::forge('/api/instance/request_access') @@ -79,7 +82,8 @@ public function test_post_request_access() ->response(); $this->assertEquals($response->status, 401); - $this->assertEquals($response->body, '"Requires an owner_id parameter"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Requires an owner_id parameter'); // == Now we're an author $this->_as_author(); @@ -101,7 +105,8 @@ public function test_post_request_access() ->execute() ->response(); - $this->assertEquals($response->body, '"Instance not found"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Instance not found'); $this->assertEquals($response->status, 404); // ======= NO OWNER ID FOUND ======== @@ -113,7 +118,8 @@ public function test_post_request_access() ->response(); $this->assertEquals($response->status, 404); - $this->assertEquals($response->body, '"Owner not found"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Owner not found'); // ======= OWNER DOES NOT OWN INSTANCE ========= // Switch users @@ -127,7 +133,8 @@ public function test_post_request_access() ->response(); $this->assertEquals($response->status, 404); - $this->assertEquals($response->body, '"Owner does not own instance"'); + $body = json_decode($response->body); + $this->assertEquals($body->msg, 'Owner does not own instance'); // ======= SUCCESSFUL REQUEST ======== $response = Request::forge('/api/instance/request_access') diff --git a/package.json b/package.json index 19889b109..c82b2ac19 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-datepicker": "^4.8.0", "react-overlays": "^5.2.1", "react-query": "^3.39.2", + "react-toastify": "^9.1.3", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/src/components/detail-carousel.jsx b/src/components/detail-carousel.jsx index 09f7a5f44..44e30c99c 100644 --- a/src/components/detail-carousel.jsx +++ b/src/components/detail-carousel.jsx @@ -55,6 +55,7 @@ const DetailCarousel = ({widget, widgetHeight=''}) => { const picScrollerRef = useRef(null) const [windowWidth] = windowSize() const createPlaySession = useCreatePlaySession() + const [error, setError] = useState(null) // Automatically adjusts screenshots based on window resize useEffect(() => { @@ -283,7 +284,10 @@ const DetailCarousel = ({widget, widgetHeight=''}) => { demoHeight: _height, demoWidth: _width, playId: idVal - }) + }), + errorFunc: (err) => { + setError("Error creating play session. Please try again later.") + } }) } else { @@ -335,7 +339,15 @@ const DetailCarousel = ({widget, widgetHeight=''}) => { ) if (demoData.showDemoCover) { - demoRender = ( + if (error) { + demoRender = ( +
+

{error}

+
+ ) + } + else demoRender = ( <>
{ if (!isError) { setExtraAttempts.mutate({ instId: inst.id, - attempts: Array.from(state.extraAttempts.values()) + attempts: Array.from(state.extraAttempts.values()), + successFunc: (data) => {}, + errorFunc: (err) => {} }) // Removed current queries from cache to force reload on next open diff --git a/src/components/header.jsx b/src/components/header.jsx index 273c965a5..a76bef1bb 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { useQuery } from 'react-query' -import { apiGetUser, apiAuthorSuper, apiAuthorSupport } from '../util/api' +import { apiGetUser, apiAuthorVerify, apiAuthorSuper, apiAuthorSupport } from '../util/api' import Notifications from './notifications' const Header = ({ @@ -9,18 +9,26 @@ const Header = ({ const [menuOpen, setMenuOpen] = useState(false) const [optionsOpen, setOptionsOpen] = useState(false) + const { data: verified} = useQuery({ + queryKey: 'isLoggedIn', + queryFn: apiAuthorVerify, + staleTime: Infinity + }) const { data: user, isLoading: userLoading} = useQuery({ queryKey: 'user', queryFn: apiGetUser, - staleTime: Infinity + staleTime: Infinity, + enabled: !!verified }) const { data: isAdmin} = useQuery({ queryKey: 'isAdmin', + enabled: !!user && user.loggedIn, queryFn: apiAuthorSuper, staleTime: Infinity }) const { data: isSupport} = useQuery({ queryKey: 'isSupport', + enabled: !!user && user.loggedIn, queryFn: apiAuthorSupport, staleTime: Infinity }) diff --git a/src/components/hooks/useCopyWidget.jsx b/src/components/hooks/useCopyWidget.jsx index 61811706a..cbe08f39f 100644 --- a/src/components/hooks/useCopyWidget.jsx +++ b/src/components/hooks/useCopyWidget.jsx @@ -12,51 +12,22 @@ export default function useCopyWidget() { return useMutation( apiCopyWidget, { - onMutate: async inst => { - await queryClient.cancelQueries('widgets', { exact: true, active: true, }) - const previousValue = queryClient.getQueryData('widgets') - - // dummy data that's appended to the query cache as an optimistic update - // this will be replaced with actual data returned from the API - const newInst = { - id: 'tmp', - widget: { - name: inst.widgetName, - dir: inst.dir - }, - name: inst.title, - is_draft: false, - is_fake: true - } - - // setQueryClient must treat the query cache as immutable!!! - // previous will contain the cached value, the function argument creates a new object from previous - queryClient.setQueryData('widgets', (previous) => ({ - ...previous, - pages: previous.pages.map((page, index) => { - if (index == 0) return { ...page, pagination: [ newInst, ...page.pagination] } - else return page - }) - })) - - return { previousValue } - }, onSuccess: (data, variables) => { - // update the query cache, which previously contained a dummy instance, with the real instance info - queryClient.setQueryData('widgets', (previous) => ({ - ...previous, - pages: previous.pages.map((page, index) => { - if (index == 0) return { ...page, pagination: page.pagination.map((inst) => { - if (inst.id == 'tmp') inst = data - return inst - }) } - else return page - }) - })) + if (queryClient.getQueryData('widgets')) + { + // optimistically update the query cache with the new instance info + queryClient.setQueryData('widgets', (previous) => ({ + ...previous, + pages: previous.pages.map((page, index) => { + if (index == 0) return { ...page, pagination: [ data, ...page.pagination] } + else return page + }) + })) + } variables.successFunc(data) }, - onError: (err, newWidget, context) => { - console.error(err) + onError: (err, variables, context) => { + variables.errorFunc(err) queryClient.setQueryData('widgets', (previous) => { return context.previousValue }) diff --git a/src/components/hooks/useCreatePlaySession.jsx b/src/components/hooks/useCreatePlaySession.jsx index 2264cfc39..54ac4f442 100644 --- a/src/components/hooks/useCreatePlaySession.jsx +++ b/src/components/hooks/useCreatePlaySession.jsx @@ -6,16 +6,11 @@ export default function useCreatePlaySession() { return useMutation( apiGetPlaySession, { - onSettled: (data, error, widgetData) => { - if (!!data) { - widgetData.successFunc(data) - } - else if (data === null) { - alert('Error: Widget demo failed to load content : is fatal') - } - else { - console.error(`failed to create play session with data: ${data}`) - } + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err, variables) => { + variables.errorFunc(err) } } ) diff --git a/src/components/hooks/useDeleteNotification.jsx b/src/components/hooks/useDeleteNotification.jsx index cd4692988..f7e128230 100644 --- a/src/components/hooks/useDeleteNotification.jsx +++ b/src/components/hooks/useDeleteNotification.jsx @@ -29,7 +29,8 @@ export default function useDeleteNotification() { // queryClient.invalidateQueries('notifications') if (data) variables.successFunc(data); }, - onError: (err, newWidget, context) => { + onError: (err, variables, context) => { + variables.errorFunc(err) queryClient.setQueryData('notifications', context.previousValue) } } diff --git a/src/components/hooks/useDeleteWidget.jsx b/src/components/hooks/useDeleteWidget.jsx index 036b564cc..46ecf3403 100644 --- a/src/components/hooks/useDeleteWidget.jsx +++ b/src/components/hooks/useDeleteWidget.jsx @@ -1,36 +1,30 @@ import { useMutation, useQueryClient } from 'react-query' import { apiDeleteWidget } from '../../util/api' +import { useState } from 'react' export default function useDeleteWidget() { const queryClient = useQueryClient() + const [instId, setInstId] = useState('') return useMutation( apiDeleteWidget, { - // Handles the optimistic update for deleting a widget - onMutate: async inst => { - await queryClient.cancelQueries('widgets') - const previousValue = queryClient.getQueryData('widgets') - + onSuccess: (data, variables) => { + // Optimistic update for deleting a widget queryClient.setQueryData('widgets', previous => { if (!previous || !previous.pages) return previous return { ...previous, pages: previous.pages.map((page) => ({ ...page, - pagination: page.pagination.filter(widget => widget.id !== inst.instId) + pagination: page.pagination.filter(widget => widget.id !== data) })) } }) - - // Stores the old value for use if there is an error - return { previousValue } - }, - onSuccess: (data, variables) => { variables.successFunc(data) }, - onError: (err, newWidget, context) => { - console.error(err) + onError: (err, variables, context) => { + variables.errorFunc(err) queryClient.setQueryData('widgets', (previous) => { return context.previousValue }) diff --git a/src/components/hooks/useInstanceList.jsx b/src/components/hooks/useInstanceList.jsx index 41577b6d2..357e6c7cb 100644 --- a/src/components/hooks/useInstanceList.jsx +++ b/src/components/hooks/useInstanceList.jsx @@ -27,7 +27,7 @@ export default function useInstanceList() { // compatibility with any downstream LTIs using the widget picker return { ...instance, - img: iconUrl(BASE_URL + 'widget/', instance.widget.dir, 275) + img: iconUrl(BASE_URL + 'widget/', instance.widget?.dir, 275) } })) ) diff --git a/src/components/hooks/usePlayLogSave.jsx b/src/components/hooks/usePlayLogSave.jsx index b8e9954c2..71c9ab1d4 100644 --- a/src/components/hooks/usePlayLogSave.jsx +++ b/src/components/hooks/usePlayLogSave.jsx @@ -5,13 +5,11 @@ export default function usePlayLogSave() { return useMutation( apiSavePlayLogs, { - onSettled: (data, error, widgetData) => { - if (!!data) { - widgetData.successFunc(data) - } - else { - widgetData.failureFunc() - } + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err, variables) => { + variables.errorFunc(err) } } ) diff --git a/src/components/hooks/usePlayStorageDataSave.jsx b/src/components/hooks/usePlayStorageDataSave.jsx index 1641a7e21..7e83dd3fc 100644 --- a/src/components/hooks/usePlayStorageDataSave.jsx +++ b/src/components/hooks/usePlayStorageDataSave.jsx @@ -5,8 +5,11 @@ export default function usePlayStorageDataSave() { return useMutation( apiSavePlayStorage, { - onSettled: (data, error, widgetData) => { - widgetData.successFunc(data) + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err, variables) => { + variables.errorFunc(err) } } ) diff --git a/src/components/hooks/useSetAttempts.jsx b/src/components/hooks/useSetAttempts.jsx index 2c5e1af98..5e6e64da4 100644 --- a/src/components/hooks/useSetAttempts.jsx +++ b/src/components/hooks/useSetAttempts.jsx @@ -5,7 +5,12 @@ export default function useSetAttempts() { return useMutation( apiSetAttempts, { - onError: () => console.error('failed to update extra attempts') + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err, variables) => { + variables.errorFunc(err) + } } ) } diff --git a/src/components/hooks/useSetUserInstancePerms.jsx b/src/components/hooks/useSetUserInstancePerms.jsx index ba13d59da..47bffee9e 100644 --- a/src/components/hooks/useSetUserInstancePerms.jsx +++ b/src/components/hooks/useSetUserInstancePerms.jsx @@ -9,7 +9,9 @@ export default function setUserInstancePerms() { { variables.successFunc(data) }, - onError: () => console.error('failed to update user perms') + onError: (err, variables) => { + variables.errorFunc(err) + } } ) } diff --git a/src/components/hooks/useSupportCopyWidget.jsx b/src/components/hooks/useSupportCopyWidget.jsx index 743b2a9bd..f850c3076 100644 --- a/src/components/hooks/useSupportCopyWidget.jsx +++ b/src/components/hooks/useSupportCopyWidget.jsx @@ -12,6 +12,10 @@ export default function useSupportCopyWidget() { queryClient.removeQueries('search-widgets', { exact: false }) + }, + onError: (data, variables) => { + variables.errorFunc(data) + console.error('Failed to copy widget: ' + err.cause) } } ) diff --git a/src/components/hooks/useSupportDeleteWidget.jsx b/src/components/hooks/useSupportDeleteWidget.jsx index 20d05c04f..46bf0bd9b 100644 --- a/src/components/hooks/useSupportDeleteWidget.jsx +++ b/src/components/hooks/useSupportDeleteWidget.jsx @@ -8,15 +8,13 @@ export default function useSupportDeleteWidget() { apiDeleteWidget, { onSuccess: (data, variables) => { - if (!!data) { - variables.successFunc() - queryClient.invalidateQueries('widgets') - } - else { - console.error('failed to delete widget') - } + variables.successFunc() + queryClient.invalidateQueries('widgets') }, - onError: () => console.error('Failed to delete widget on backend') + onError: (err, variables) => { + variables.errorFunc(err) + console.error('Failed to delete widget: ' + err.cause) + } } ) } diff --git a/src/components/hooks/useSupportUnDeleteWidget.jsx b/src/components/hooks/useSupportUnDeleteWidget.jsx index 1f600f26f..9116f3c30 100644 --- a/src/components/hooks/useSupportUnDeleteWidget.jsx +++ b/src/components/hooks/useSupportUnDeleteWidget.jsx @@ -8,17 +8,15 @@ export default function useSupportUnDeleteWidget() { apiUnDeleteWidget, { onSuccess: (data, variables) => { - if (!!data) { - variables.successFunc() - queryClient.removeQueries('search-widgets', { - exact: false - }) - } - else { - console.error('failed to undelete widget') - } + variables.successFunc() + queryClient.removeQueries('search-widgets', { + exact: false + }) }, - onError: () => console.error('Failed to undelete widget on backend') + onError: (err, variables) => { + variables.errorFunc(err) + console.error('Failed to restore widget: ' + err.cause) + } } ) } diff --git a/src/components/hooks/useSupportUpdateWidget.jsx b/src/components/hooks/useSupportUpdateWidget.jsx index 5359721d3..5be245fe0 100644 --- a/src/components/hooks/useSupportUpdateWidget.jsx +++ b/src/components/hooks/useSupportUpdateWidget.jsx @@ -18,10 +18,9 @@ export default function useSupportUpdateWidget() { exact: false }) }, - onError: (err, newWidget, context) => { - queryClient.setQueryData('widgets', context.previousValue) - - variables.errorFunc() + onError: (err, variables, context) => { + variables.errorFunc(err) + console.error('Failed to update widget: ' + err.cause) } } ) diff --git a/src/components/hooks/useUpdateUserRoles.jsx b/src/components/hooks/useUpdateUserRoles.jsx index 38ff42294..bbb723c3e 100644 --- a/src/components/hooks/useUpdateUserRoles.jsx +++ b/src/components/hooks/useUpdateUserRoles.jsx @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query' import { apiUpdateUserRoles } from '../../util/api' export default function useUpdateUserRoles() { - + const queryClient = useQueryClient() return useMutation( @@ -21,9 +21,9 @@ export default function useUpdateUserRoles() { queryClient.invalidateQueries('search-users') variables.successFunc(data) }, - onError: (err, newRoles, context) => { + onError: (err, variables, context) => { queryClient.setQueryData('search-users', context.previousValue) - return err + variables.errorFunc(err) } } ) diff --git a/src/components/hooks/useUpdateUserSettings.jsx b/src/components/hooks/useUpdateUserSettings.jsx index b3d4a04d6..13cbaed03 100644 --- a/src/components/hooks/useUpdateUserSettings.jsx +++ b/src/components/hooks/useUpdateUserSettings.jsx @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query' import { apiUpdateUserSettings } from '../../util/api' export default function useUpdateUserSettings() { - + const queryClient = useQueryClient() return useMutation( @@ -17,11 +17,13 @@ export default function useUpdateUserSettings() { return { prior } }, - onSuccess: (data, newSettings, context) => { + onSuccess: (data, variables, context) => { + variables.successFunc() queryClient.invalidateQueries('user') }, - onError: (err, newSettings, context) => { + onError: (err, variables, context) => { + variables.errorFunc(err) queryClient.setQueryData('user', context.previousValue) } } diff --git a/src/components/hooks/useUpdateWidget.jsx b/src/components/hooks/useUpdateWidget.jsx index 39321e331..146c2b5e4 100644 --- a/src/components/hooks/useUpdateWidget.jsx +++ b/src/components/hooks/useUpdateWidget.jsx @@ -33,7 +33,7 @@ export default function useUpdateWidget() { } } } - + // update query cache for widgets. This does NOT invalidate the cache, forcing a re-fetch!! queryClient.setQueryData('widgets', widgetList) @@ -43,8 +43,7 @@ export default function useUpdateWidget() { }, onError: (err, variables, previous) => { // write previously intact widget list into the query cache. This should be the same data as before. - queryClient.setQueryData('widgets', previous) - variables.successFunc(null) + variables.errorFunc(err) } } ) diff --git a/src/components/include.scss b/src/components/include.scss index 5f8cc04b0..0fe4186e5 100644 --- a/src/components/include.scss +++ b/src/components/include.scss @@ -756,40 +756,39 @@ h1.logo { .page, .widget { position: relative; z-index: 1; +} - .alert-wrapper { - position: absolute; - top: 0; - left: 0; - z-index: 9998; - width: 100%; - height: 100%; - background: rgba(0,0,0,0.66); +.alert-wrapper { + position: fixed; + top: 0; + left: 0; + z-index: 9998; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.66); + display: flex; + justify-content: center; + align-items: center; + + .alert-dialog { + width: 400px; + text-align: center; + padding: 15px; + background: #fff; - .alert-dialog { - position: absolute; - top: 25%; - left: 50%; - width: 400px; - margin-left: -200px; - text-align: center; - padding: 15px; - background: #fff; - - h3 { - margin: 5px 0; - font-size: 1.2em; - } + h3 { + margin: 5px 0; + font-size: 1.2em; + } - .buttons { - display: block; - width: 100%; - margin-top: 5px; + .buttons { + display: block; + width: 100%; + margin-top: 5px; - .action_button { - display: inline-block; - margin: 10px 0 5px 0; - } + .action_button { + display: inline-block; + margin: 10px 0 5px 0; } } } @@ -880,4 +879,49 @@ footer { border-width: 28px; margin-top: -28px; } +} + + +.error { + p { + min-width: 340px; + padding: 6px 10px; + margin: 25px auto; + background: #ffcfcf; + + color: red; + font-weight: 600; + font-size: 17px; + text-align: center; + box-sizing: border-box; + + } +} + +.success { + p { + min-width: 340px; + padding: 6px 10px; + margin: 25px auto; + background: #c4ffdf; + + color: green; + font-weight: 600; + font-size: 17px; + text-align: center; + box-sizing: border-box; + + } +} + +.no_permission { + ul { + width: 360px; + margin: auto; + text-align: left; + } +} + +.notif-error { + color: red; } \ No newline at end of file diff --git a/src/components/lti/open-preview.jsx b/src/components/lti/open-preview.jsx index 54b61f024..31788f4dc 100644 --- a/src/components/lti/open-preview.jsx +++ b/src/components/lti/open-preview.jsx @@ -45,8 +45,10 @@ const SelectItem = () => { const requestAccess = async (ownerID) => { await apiRequestAccess(instID, ownerID).then((data) => { - if (data) setRequestSuccess('Request succeeded') - else setRequestSuccess('Request Failed') + setRequestSuccess('Request succeeded') + setRequestSuccessID(ownerID) + }).catch(err => { + setRequestSuccess('Request Failed') setRequestSuccessID(ownerID) }) } diff --git a/src/components/media-importer.jsx b/src/components/media-importer.jsx index 11c5d9379..ac2397da2 100644 --- a/src/components/media-importer.jsx +++ b/src/components/media-importer.jsx @@ -68,9 +68,8 @@ const MediaImporter = () => { queryKey: ['media-assets', selectedAsset], queryFn: () => apiGetAssets(), staleTime: Infinity, - onSettled: (data) => { - if (!data || data.type == 'error') console.error(`Asset request failed with error: ${data.msg}`) - else { + onSuccess: (data) => { + if (data) { const list = data.map(asset => { const creationDate = new Date(asset.created_at * 1000) return { @@ -90,6 +89,9 @@ const MediaImporter = () => { setAssetList(list) } + }, + onError: (err) => { + console.error(`Asset request failed with error: ${err.cause}`) } }) diff --git a/src/components/my-widgets-collaborate-dialog.jsx b/src/components/my-widgets-collaborate-dialog.jsx index 560e63062..2d0eae427 100644 --- a/src/components/my-widgets-collaborate-dialog.jsx +++ b/src/components/my-widgets-collaborate-dialog.jsx @@ -23,14 +23,29 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set const debouncedSearchTerm = useDebounce(state.searchText, 250) const queryClient = useQueryClient() const setUserPerms = setUserInstancePerms() + const [error, setError] = useState('') const mounted = useRef(false) const popperRef = useRef(null) - const { data: collabUsers, remove: clearUsers, isFetching} = useQuery({ + const [collabUsers, setCollabUsers] = useState({}) + + const { data, remove: clearUsers, isFetching} = useQuery({ queryKey: ['collab-users', inst.id, (otherUserPerms != null ? Array.from(otherUserPerms.keys()) : otherUserPerms)], // check for changes in otherUserPerms - enabled: !!otherUserPerms, + enabled: !!otherUserPerms && Array.from(otherUserPerms.keys()).length > 0, queryFn: () => apiGetUsers(Array.from(otherUserPerms.keys())), staleTime: Infinity, - placeholderData: {} + placeholderData: {}, + onSuccess: (data) => { + setCollabUsers({...collabUsers,...data}) + }, + onError: (err) => { + if (err.message == "Invalid Login") + { + setInvalidLogin(true) + customClose() + } else { + setError("Failed to load users") + } + } }) const { data: searchResults, remove: clearSearch, refetch: refetchSearch } = useQuery({ @@ -40,16 +55,13 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set staleTime: Infinity, placeholderData: [], retry: false, - onSuccess: (data) => { - if (data && data.type == 'error') + onError: (err) => { + if (err.message == "Invalid Login") { - console.error(`User search failed with error: ${data.msg}`); - if (data.title == "Invalid Login") - { - setInvalidLogin(true) - } - } else if (!data) { - console.error(`User search failed.`); + setInvalidLogin(true) + customClose() + } else { + setError("Failed to search users") } } }) @@ -97,7 +109,9 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set tmpMatch[match.id] = match queryClient.setQueryData(['collab-users', inst.id], old => ({...old, ...tmpMatch})) if (!collabUsers[match.id]) - collabUsers[match.id] = match + { + setCollabUsers({...collabUsers, [match.id]: match}) + } // Updateds the perms tempPerms.set( @@ -170,14 +184,7 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set instId: inst.id, permsObj: permsObj, successFunc: (data) => { - if (data && data.type == 'error') - { - if (data.title == "Share Not Allowed") - { - setState({...state, shareNotAllowed: true}) - } - } - else if (mounted.current) { + if (mounted.current) { if (delCurrUser) { queryClient.invalidateQueries('widgets') } @@ -189,6 +196,17 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set setOtherUserPerms(state.updatedAllUserPerms) customClose() } + }, + errorFunc: (err) => { + if (err.message == "Share Not Allowed") + { + setState({...state, shareNotAllowed: true}) + } else if (err.message == "Invalid Login") + { + setInvalidLogin(true) + } else { + setError("Failed to save permissions") + } } }) @@ -219,7 +237,7 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set let searchContainerRender = null if (myPerms?.shareable || myPerms?.isSupportUser) { let searchResultsRender = null - if (debouncedSearchTerm !== '' && state.searchText !== '' && searchResults.length && searchResults?.length !== 0) { + if (debouncedSearchTerm !== '' && state.searchText !== '' && searchResults?.length && searchResults?.length !== 0) { const searchResultElements = searchResults?.map(match =>
+

{error}

+
+ ) + } + return (
Collaborate
+ { errorRender }
{ searchContainerRender }
diff --git a/src/components/my-widgets-collaborate-dialog.scss b/src/components/my-widgets-collaborate-dialog.scss index 49c664af7..1c2562695 100644 --- a/src/components/my-widgets-collaborate-dialog.scss +++ b/src/components/my-widgets-collaborate-dialog.scss @@ -1,7 +1,6 @@ // Collaborate dialog .modal .collaborate-modal { width: 620px; - height: 500px; font-family: 'Lato', arial, serif; @@ -16,7 +15,7 @@ position: relative; text-align: left; display: block; - + font-weight: bold; } @@ -72,10 +71,10 @@ width: 447px; padding-bottom: 5px; overflow: auto; - + background-color: #ffffff; border: #bfbfbf 1px solid; - + text-align: left; .collab-search-match { @@ -101,7 +100,7 @@ margin: 5px 0 0 5px; font-size: 14px; text-align: left; - + font-family: 'Lucida Grande', sans-serif; } } diff --git a/src/components/my-widgets-copy-dialog.jsx b/src/components/my-widgets-copy-dialog.jsx index fbb88dcf9..b5301f2ef 100644 --- a/src/components/my-widgets-copy-dialog.jsx +++ b/src/components/my-widgets-copy-dialog.jsx @@ -1,14 +1,52 @@ import React, { useState } from 'react' import Modal from './modal' import './my-widgets-copy-dialog.scss' +import useCopyWidget from './hooks/useCopyWidget' -const MyWidgetsCopyDialog = ({onClose, onCopy, name}) => { +const MyWidgetsCopyDialog = ({inst, name, onClose, onCopySuccess, onCopyError}) => { const [newTitle, setNewTitle] = useState(`${name} (Copy)`) const [copyPermissions, setCopyPermissions] = useState(false) + const [errorText, setErrorText] = useState('') + const copyWidget = useCopyWidget() const handleTitleChange = e => setNewTitle(e.target.value) const handleOwnerAccessChange = e => setCopyPermissions(e.target.checked) - const handleCopyClick = () => onCopy(newTitle, copyPermissions) + const handleCopyClick = () => onCopy(inst.id, newTitle, copyPermissions, inst) + + // an instance has been copied: the mutation will optimistically update the widget list while the list is re-fetched from the server + const onCopy = (instId, newTitle, newPerm, inst) => { + // setState({ ...state, selectedInst: null }) + setErrorText('') + + copyWidget.mutate( + { + instId: instId, + title: newTitle, + copyPermissions: newPerm, + widgetName: inst.widget.name, + dir: inst.widget.dir, + successFunc: newInst => { + onCopySuccess(newInst) + }, + errorFunc: (err) => { + setErrorText('Error: Copy Unsuccessful') + if (onCopyError) onCopyError(err) + else if (err.message == "Invalid Login") { + window.location.href = '/users/login' + return + } + else if (err.message == "Permission Denied") { + setErrorText('Permission Denied') + } + } + } + ) + } + + let error = null + if (errorText) { + error =

{errorText}

+ } return ( @@ -16,6 +54,7 @@ const MyWidgetsCopyDialog = ({onClose, onCopy, name}) => { Make a Copy
+ { error }
{ currentBeard: '' }) + const [alertDialog, setAlertDialog] = useState({ + enabled: false, + message: '', + title: 'Failure', + fatal: false, + enableLoginButton: false + }) + const instanceList = useInstanceList() const [invalidLogin, setInvalidLogin] = useState(false) const [showCollab, setShowCollab] = useState(false) const [beardMode, setBeardMode] = useState(!!localBeard ? localBeard === 'true' : false) const validCode = useKonamiCode() - const copyWidget = useCopyWidget() const deleteWidget = useDeleteWidget() const { data: user } = useQuery({ queryKey: 'user', queryFn: apiGetUser, - staleTime: Infinity + staleTime: Infinity, + retry: false, + onError: (err) => { + if (err.message == "Invalid Login") + { + setInvalidLogin(true) + } else { + setAlertDialog({ + enabled: true, + message: 'Failed to get user data.', + title: err.message, + fatal: err.halt, + enableLoginButton: false + }) + } + } }) const { data: permUsers } = useQuery({ @@ -57,7 +79,13 @@ const MyWidgetsPage = () => { queryFn: () => apiGetUserPermsForInstance(state.selectedInst?.id), enabled: !!state.selectedInst && !!state.selectedInst.id && state.selectedInst?.id !== undefined, placeholderData: null, - staleTime: Infinity + staleTime: Infinity, + retry: false, + onError: (err) => { + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + } }) // konami code activate (or deactivate) @@ -70,7 +98,15 @@ const MyWidgetsPage = () => { // hook associated with the invalidLogin error useEffect(() => { - if (invalidLogin) window.location.reload(); + if (invalidLogin) { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to view your widgets.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } }, [invalidLogin]) // hook to attach the hashchange event listener to the window @@ -203,63 +239,27 @@ const MyWidgetsPage = () => { window.history.pushState(document.body.innerHTML, document.title, `#${inst.id}`) } - // an instance has been copied: the mutation will optimistically update the widget list while the list is re-fetched from the server - const onCopy = (instId, newTitle, newPerm, inst) => { - setState({ ...state, selectedInst: null }) - - copyWidget.mutate( - { - instId: instId, - title: newTitle, - copyPermissions: newPerm, - widgetName: inst.widget.name, - dir: inst.widget.dir, - successFunc: (data) => { - if (data && (data.type == 'error')) - { - console.error(`Failed to copy widget with error: ${data.msg}`); - if (data.title == "Invalid Login") - { - setInvalidLogin(true) - } - } - } - }, - { - // Still waiting on the widget list to refresh, return to a 'loading' state and indicate a post-fetch change is coming. - onSettled: newInst => { - setState({ - ...state, - selectedInst: null, - widgetHash: newInst.id - }) - } - } - ) - } - // an instance has been deleted: the mutation will optimistically update the widget list while the list is re-fetched from the server const onDelete = inst => { deleteWidget.mutate( { instId: inst.id, - successFunc: (data) => { - if (data && data.type == 'error') + errorFunc: (err) => { + if (err.message == "Invalid Login") { - console.error(`Error: ${data.msg}`); - if (data.title == "Invalid Login") - { - setInvalidLogin(true) - } - } else if (!data) { - console.error(`Delete widget failed.`); + setInvalidLogin(true) + } else { + setAlertDialog({ + enabled: true, + message: 'Failed to delete widget.', + title: err.message, + fatal: err.halt, + enableLoginButton: false + }) } - } - }, - { - // Still waiting on the widget list to refresh, return to a 'loading' state and indicate a post-fetch change is coming. - onSettled: () => { + }, + successFunc: (data) => { setState({ ...state, selectedInst: null, @@ -300,6 +300,20 @@ const MyWidgetsPage = () => { ) } + let alertDialogRender = null + if (alertDialog.enabled) { + alertDialogRender = ( + { + setAlertDialog({...alertDialog, enabled: false}) + }} /> + ) + } + /** * If the user is loading, show a loading screen. If the user is fetching, show a loading screen. If * the user has no widgets, show a message. If the user has no selected widget, show a message. If the @@ -355,7 +369,6 @@ const MyWidgetsPage = () => { return {
{widgetCatalogCalloutRender} + {alertDialogRender}
diff --git a/src/components/my-widgets-scores.jsx b/src/components/my-widgets-scores.jsx index c13e6429b..67bc86132 100644 --- a/src/components/my-widgets-scores.jsx +++ b/src/components/my-widgets-scores.jsx @@ -24,20 +24,22 @@ const MyWidgetsScores = ({inst, beardMode}) => { // Initializes the data when widget changes useEffect(() => { let hasScores = false + if (currScores) { + currScores.map(val => { + if (val.distribution) hasScores = true + }) - currScores.map(val => { - if (val.distribution) hasScores = true - }) - - setState({ - hasScores: hasScores, - showExport: false - }) + setState({ + hasScores: hasScores, + showExport: false + }) + } }, [JSON.stringify(currScores)]) const displayedSemesters = useMemo(() => { if (currScores && (state.isShowingAll || currScores.length < 2)) return currScores // all semester being displayed - return currScores.slice(0,1) // show just one semester, gracefully handles empty array + else if (currScores) return currScores.slice(0,1) // show just one semester, gracefully handles empty array + else return [] // no scores yet }, [currScores, state.isShowingAll]) const openExport = () => { diff --git a/src/components/my-widgets-selected-instance.jsx b/src/components/my-widgets-selected-instance.jsx index 9c0eb80c8..27ae92135 100644 --- a/src/components/my-widgets-selected-instance.jsx +++ b/src/components/my-widgets-selected-instance.jsx @@ -56,7 +56,6 @@ const MyWidgetSelectedInstance = ({ otherUserPerms, setOtherUserPerms, onDelete, - onCopy, onEdit, beardMode, beard, @@ -79,16 +78,13 @@ const MyWidgetSelectedInstance = ({ placeholderData: null, enabled: !!inst.id, staleTime: Infinity, - onSuccess: (data) => { - if (data && data.type == 'error') + retry: false, + onError: (err) => { + if (err.message == "Invalid Login") { - console.error(`Error: ${data.msg}`); - if (data.title == "Invalid Login") - { - setInvalidLogin(true) - } - } else if (!data) { - console.error(`Failed to fetch permissions.`); + setInvalidLogin(true) + } else { + console.error(err) } } }) @@ -142,11 +138,6 @@ const MyWidgetSelectedInstance = ({ } }, [myPerms, inst]) - const makeCopy = useCallback((title, copyPermissions) => { - setShowCopy(false) - onCopy(inst.id, title, copyPermissions, inst) - }, [inst, setShowCopy]) - const onEditClick = inst => { if (inst.widget.is_editable && state.perms.editable && editPerms && !permsFetching) { const editUrl = `${window.location.origin}/widgets/${inst.widget.dir}create#${inst.id}` @@ -282,13 +273,29 @@ const MyWidgetSelectedInstance = ({ const toggleShowEmbed = () => setShowEmbed(!showEmbed) - const copyDialogOnClose = () => closeModal(setShowCopy) + const onCopyClose = () => { + closeModal(setShowCopy) + } + const onCopySuccess = (newInst) => { + setState({ ...state, selectedInst: null, widgetHash: newInst.id }) + window.location.hash = newInst.id; + onCopyClose() + } + const onCopyError = (err) => { + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + } + let copyDialogRender = null if (showCopy) { copyDialogRender = ( - ) } diff --git a/src/components/my-widgets-settings-dialog.jsx b/src/components/my-widgets-settings-dialog.jsx index 4e14f40fd..ca9f209b2 100644 --- a/src/components/my-widgets-settings-dialog.jsx +++ b/src/components/my-widgets-settings-dialog.jsx @@ -84,16 +84,10 @@ const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, o placeholderData: {}, enabled: !!otherUserPerms && Array.from(otherUserPerms.keys())?.length > 0, staleTime: Infinity, - onSuccess: (data) => { - if (data && data.type == 'error') - { - console.error(`Error: ${data.msg}`); - if (data.title == "Invalid Login") - { - setInvalidLogin(true) - } - } else if (!data) { - console.error('Failed to fetch users.') + onError: (err) => { + console.error(`Error: ${err.message}`); + if (err.message == "Invalid Login") { + setInvalidLogin(true); } } }) @@ -263,21 +257,14 @@ const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, o mutateWidget.mutate({ args: args, successFunc: (updatedInst) => { - - if (!updatedInst || updatedInst.type == "error") { - - if (updatedInst.title == "Invalid Login") { - setInvalidLogin(true); - } - else { - console.error(`Error: ${updatedInst.msg}`); - setState({...state, errorLabel: 'Something went wrong, and your changes were not saved.'}) - } - } - else { - onEdit(updatedInst) - if (mounted.current) onClose() + onEdit(updatedInst) + if (mounted.current) onClose() + }, + errorFunc: (err) => { + if (err.message == "Invalid Login") { + setInvalidLogin(true); } + else setState({...state, errorLabel: 'Something went wrong, and your changes were not saved.'}) } }) } @@ -426,9 +413,9 @@ const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, o let errorLabelRender = null if (state.errorLabel.length > 0) { errorLabelRender = ( -

- {state.errorLabel} -

+
+

{state.errorLabel}

+
) } @@ -479,9 +466,9 @@ const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, o
Settings - { errorLabelRender }
{ studentLimitWarningRender } + { errorLabelRender }
  • Attempts

    diff --git a/src/components/my-widgets-settings-dialog.scss b/src/components/my-widgets-settings-dialog.scss index 419709eef..4e56ccf73 100644 --- a/src/components/my-widgets-settings-dialog.scss +++ b/src/components/my-widgets-settings-dialog.scss @@ -2,7 +2,6 @@ display: flex; flex-direction: column; width: 680px; - max-height: 675px; padding: 10px 10px 0 10px; .top-bar { @@ -10,7 +9,6 @@ flex-direction: row; border-bottom: #999 dotted 1px; padding-bottom: 20px; - margin-bottom: 20px; align-items: center; .title { @@ -48,7 +46,7 @@ .attempt-content { display: flex; - margin-bottom: 5px; + margin: 15px 0 5px 0; &.hide { display: none; diff --git a/src/components/notifications.jsx b/src/components/notifications.jsx index a074bcf05..a59b72b66 100644 --- a/src/components/notifications.jsx +++ b/src/components/notifications.jsx @@ -19,7 +19,7 @@ const Notifications = (user) => { const { data: notifications} = useQuery({ queryKey: 'notifications', - enabled: user?.loggedIn, + enabled: !!user && user.loggedIn, retry: false, refetchInterval: 60000, refetchOnMount: false, @@ -31,6 +31,13 @@ const Notifications = (user) => { if (data && data.length > 0) data.forEach(element => { if (!element.remove) numNotifications.current++; }); + }, + onError: (err) => { + if (err.message == "Invalid Login") { + window.location.href = '/users/login' + } else { + console.error(err) + } } }) @@ -84,6 +91,9 @@ const Notifications = (user) => { return; } }) + }, + errorFunc: (err) => { + setErrorMsg({notif_id: id, msg: 'Action failed.'}); } }); } @@ -92,7 +102,8 @@ const Notifications = (user) => { deleteNotification.mutate({ notifId: '', deleteAll: true, - successFunc: () => {} + successFunc: () => {}, + errorFunc: (err) => {} }); } @@ -124,33 +135,29 @@ const Notifications = (user) => { instId: notif.item_id, permsObj: userPerms, successFunc: (data) => { - if (data && data.status == 200) + // Redirect to widget + if (!window.location.pathname.includes('my-widgets')) { - // Redirect to widget - if (!window.location.pathname.includes('my-widgets')) - { - // No idea why this works - // But setting hash after setting pathname would set the hash first and then the pathname in URL - window.location.hash = notif.item_id + '-collab'; - window.location.pathname = '/my-widgets' - } - else - { - queryClient.invalidateQueries(['user-perms', notif.item_id]) - window.location.hash = notif.item_id + '-collab'; - } - - setErrorMsg({notif_id: notif.id, msg: ''}); - - removeNotification(-1, notif.id); - - // Close notifications - setNavOpen(false) + // No idea why this works + // But setting hash after setting pathname would set the hash first and then the pathname in URL + window.location.hash = notif.item_id + '-collab'; + window.location.pathname = '/my-widgets' } else { - setErrorMsg({notif_id: notif.id, msg: 'Action failed.'}); + queryClient.invalidateQueries(['user-perms', notif.item_id]) + window.location.hash = notif.item_id + '-collab'; } + + setErrorMsg({notif_id: notif.id, msg: ''}); + + removeNotification(-1, notif.id); + + // Close notifications + setNavOpen(false) + }, + errorFunc: (err) => { + setErrorMsg({notif_id: notif.id, msg: 'Action failed.'}) } }) @@ -201,7 +208,7 @@ const Notifications = (user) => { className={`noticeClose ${showDeleteBtn == index ? 'show' : ''}`} onClick={() => {removeNotification(index)}} /> -

    {errorMsg.notif_id == notification.id ? errorMsg.msg : ''}

    +

    {errorMsg.notif_id == notification.id ? errorMsg.msg : ''}

notificationIcon = diff --git a/src/components/pre-embed-common-styles.scss b/src/components/pre-embed-common-styles.scss index 486556d51..a67fbc50e 100644 --- a/src/components/pre-embed-common-styles.scss +++ b/src/components/pre-embed-common-styles.scss @@ -37,7 +37,7 @@ body { vertical-align: middle; } } - + } section.page { @@ -53,7 +53,7 @@ body { box-shadow: 1px 3px 10px #dcdcdc; &.is-draft { - + p { width: 80%; margin: 10px auto; @@ -145,7 +145,7 @@ body { } } - + } ul.widget_about { @@ -163,7 +163,7 @@ body { padding: 0 0 5px 0; font-weight: 700; font-size: 38px; - + line-height: 37px; text-align: left; } @@ -187,7 +187,7 @@ body { li { display: inline; - &:after { + &:after { content: '|'; } @@ -220,7 +220,7 @@ body { height: 40px; padding-left: 12px; padding-right: 12px; - + font-size: 14px; font-weight: 200; @@ -252,12 +252,12 @@ body { padding: 6px 10px; margin: 25px auto 0; background: #ffcfcf; - + color: red; font-weight: 600; font-size: 17px; text-align: center; - + } } diff --git a/src/components/profile-page.jsx b/src/components/profile-page.jsx index 3b1b37d89..1992b20ac 100644 --- a/src/components/profile-page.jsx +++ b/src/components/profile-page.jsx @@ -6,14 +6,31 @@ import Header from './header' import './profile-page.scss' const ProfilePage = () => { - + const [alertDialog, setAlertDialog] = useState({ + enabled: false, + message: '', + title: 'Failure', + fatal: false, + enableLoginButton: false + }) const [activityPage, setActivityPage] = React.useState(0) const mounted = useRef(false) const { data: currentUser, isFetching} = useQuery({ queryKey: 'user', queryFn: apiGetUser, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + if (err.message == "Invalid Login") { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to view your profile.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } + } }) const { @@ -28,7 +45,18 @@ const ProfilePage = () => { getNextPageParam: (lastPage, pages) => { return lastPage.more == true ? activityPage : undefined }, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + if (err.message == "Invalid Login") { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to view your profile.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } + } }) useEffect(() => { @@ -79,8 +107,22 @@ const ProfilePage = () => { }) }) + let alertDialogRender = null + if (alertDialog.enabled) { + alertDialogRender = ( + { + setAlertDialog({...alertDialog, enabled: false}) + }} /> + ) + } + let mainContentRender =
- if ( !isFetching && !isFetchingActivity ) { + if ( !isFetching && !isFetchingActivity && currentUser) { mainContentRender =
@@ -127,6 +169,7 @@ const ProfilePage = () => {
+ { alertDialogRender } { mainContentRender }
diff --git a/src/components/question-history.jsx b/src/components/question-history.jsx index deb21a373..91fe737d9 100644 --- a/src/components/question-history.jsx +++ b/src/components/question-history.jsx @@ -12,17 +12,22 @@ const getInstId = () => { const QuestionHistory = () => { const [saves, setSaves] = useState([]) + const [error, setError] = useState('') const [instId, setInstId] = useState(getInstId()) const { data: qsetHistory, isLoading: loading } = useQuery({ queryKey: ['questions', instId], queryFn: () => apiGetQuestionSetHistory(instId), enabled: !!instId, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + setError("Error fetching question set history.") + console.error(err.cause) + } }) useEffect(() => { - if (qsetHistory && qsetHistory.type != 'error') + if (qsetHistory) { qsetHistory.map((qset) => { return { @@ -38,8 +43,10 @@ const QuestionHistory = () => { }, [qsetHistory]) const readQuestionCount = (qset) => { - let items = qset.items - if (items.items) items = items.items + let items = qset + // recursively get qset.items + if (items.items) + return readQuestionCount(items.items) return items.length } @@ -62,7 +69,21 @@ const QuestionHistory = () => { let savesRender = null let noSavesRender = null - if (!!saves && saves.length > 0) { + if (loading) { + noSavesRender = ( +
+

Loading...

+
+ ) + } + else if (error) { + noSavesRender = ( +
+

{error}

+
+ ) + } + else if (!!saves && saves.length > 0) { savesRender = saves.map((save, index) => { return ( loadSaveData(save.id)} key={index}> diff --git a/src/components/question-importer.jsx b/src/components/question-importer.jsx index 830577bc6..d951ef4ab 100644 --- a/src/components/question-importer.jsx +++ b/src/components/question-importer.jsx @@ -27,7 +27,7 @@ const QuestionImporter = () => { }) useEffect(() => { - if ( ! isLoading) { + if ( ! isLoading && allQuestions && allQuestions.length) { // add a 'selected' property with a default value of false to each question const formattedAllQuestions = allQuestions.map(q => ({...q, selected: false})) setState({...state, allQuestions: formattedAllQuestions, displayQuestions: formattedAllQuestions}) diff --git a/src/components/scores.jsx b/src/components/scores.jsx index 63219b410..97eb194f6 100644 --- a/src/components/scores.jsx +++ b/src/components/scores.jsx @@ -76,11 +76,15 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview enabled: false, // enabled is set to false so the query can be manually called with the refetch function staleTime: Infinity, refetchOnWindowFocus: false, - onSettled: (result) => { - if (result && result.type == 'error') setErrorState(STATE_RESTRICTED) - else { - _populateScores(result.scores) - setAttemptsLeft(result.attempts_left) + onSuccess: (result) => { + _populateScores(result.scores) + setAttemptsLeft(result.attempts_left) + }, + onError: (err) => { + if (err.message == "Invalid Login") { + setErrorState(STATE_RESTRICTED) + } else { + setErrorState(STATE_INVALID) } } }) @@ -96,9 +100,15 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview staleTime: Infinity, retry: false, refetchOnWindowFocus: false, - onSettled: (result) => { - if (result && result.type == 'error') setErrorState(STATE_RESTRICTED) - else _populateScores(result) + onSuccess: (result) => { + _populateScores(result) + }, + onError: (error) => { + if (error.message == "Invalid Login") { + setErrorState(STATE_RESTRICTED) + } else { + setErrorState(STATE_INVALID) + } } }) @@ -111,11 +121,13 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview enabled: (!!playId || !!previewInstId), retry: false, refetchOnWindowFocus: false, - onSettled: (result) => { - if (isPreview && (!result || result.length < 1)) { + onError: (err) => { + if (err.message == "Invalid Login") { + setErrorState(STATE_RESTRICTED) + } else if (isPreview) { setAttributes({...attributes, href: `/preview/${inst_id}/${instance?.clean_name}`}) setErrorState(STATE_EXPIRED) - } else if (!result || result.length < 1) { + } else { setErrorState(STATE_INVALID) } } @@ -127,8 +139,15 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview queryFn: () => apiGetScoreDistribution(inst_id), enabled: false, staleTime: Infinity, - onSettled: (data) => { + onSuccess: (data) => { _sendToWidget('scoreDistribution', [data]) + }, + onError: (err) => { + if (err.message == "Invalid Login") { + setErrorState(STATE_RESTRICTED) + } else { + setErrorState(STATE_INVALID) + } } }) diff --git a/src/components/settings-page.jsx b/src/components/settings-page.jsx index d778b591f..b040660e0 100644 --- a/src/components/settings-page.jsx +++ b/src/components/settings-page.jsx @@ -5,18 +5,37 @@ import {apiGetUser} from '../util/api' import useUpdateUserSettings from './hooks/useUpdateUserSettings' import Header from './header' import './profile-page.scss' +import Alert from './alert' const SettingsPage = () => { - + const [alertDialog, setAlertDialog] = useState({ + enabled: false, + message: '', + title: 'Failure', + fatal: false, + enableLoginButton: false + }) + const [error, setError] = useState('') const mounted = useRef(false) const { data: currentUser, isFetching} = useQuery({ queryKey: 'user', queryFn: apiGetUser, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + if (err.message == "Invalid Login") { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to view your settings.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } + } }) useEffect(() => { - if (mounted && ! isFetching) { + if (mounted && ! isFetching && currentUser) { mounted.current = true setState({...state, notify: currentUser.profile_fields.notify, useGravatar: currentUser.profile_fields.useGravatar}) return () => (mounted.current = false) @@ -39,12 +58,54 @@ const SettingsPage = () => { const _submitSettings = () => { mutateUserSettings.mutate({ notify: state.notify, - useGravatar: state.useGravatar + useGravatar: state.useGravatar, + successFunc: () => {}, + errorFunc: (err) => { + if (err.message == "Invalid Login") { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to view your settings.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } else if (err.message == "Unauthorized") { + setAlertDialog({ + enabled: true, + message: 'You do not have permission to view this page.', + title: 'Action Failed', + fatal: err.halt, + enableLoginButton: false + }) + } + setError("Error updating settings.") + } }) } + let errorRender = null + if (error) { + errorRender = ( +

{error}

+ ) + } + + let alertDialogRender = null + if (alertDialog.enabled) { + alertDialogRender = ( + { + setAlertDialog({...alertDialog, enabled: false}) + }} /> + ) + } + let mainContentRender =
- if ( !isFetching ) { + if ( !isFetching && currentUser ) { mainContentRender =
    @@ -83,6 +144,8 @@ const SettingsPage = () => {
+ { errorRender } +
@@ -91,6 +154,7 @@ const SettingsPage = () => { return ( <>
+ { alertDialogRender }
{ mainContentRender } diff --git a/src/components/support-page.scss b/src/components/support-page.scss index eba3e84d0..8cd427cce 100644 --- a/src/components/support-page.scss +++ b/src/components/support-page.scss @@ -267,18 +267,6 @@ .apply { padding: 6px 10px 6px 10px; } - - .error-text { - color: red; - font-size: 0.8em; - text-align: center; - } - - .success-text { - color: green; - font-size: 0.8em; - text-align: center; - } } } } @@ -377,6 +365,17 @@ bottom: 5px; } } + + .failed { + color:rgb(202, 0, 0); + } + .success-holder { + margin: 5px; + } + + .success { + color: green; + } } } @@ -429,7 +428,6 @@ .modal .copy-modal { width: 620px; - height: 330px; padding: 10px 10px 0 10px; .title { diff --git a/src/components/support-selected-instance.jsx b/src/components/support-selected-instance.jsx index ae062a837..c61e6ca72 100644 --- a/src/components/support-selected-instance.jsx +++ b/src/components/support-selected-instance.jsx @@ -9,7 +9,7 @@ import useUpdateWidget from './hooks/useSupportUpdateWidget' import MyWidgetsCopyDialog from './my-widgets-copy-dialog' import MyWidgetsCollaborateDialog from './my-widgets-collaborate-dialog' import ExtraAttemptsDialog from './extra-attempts-dialog' -import useCopyWidget from './hooks/useSupportCopyWidget' +import Alert from './alert' const addZero = i => `${i}`.padStart(2, '0') @@ -47,11 +47,19 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => { const [closeTime, setCloseTime] = useState(inst.close_at < 0 ? '' : objToTimeString(inst.close_at)) const [errorText, setErrorText] = useState('') const [successText, setSuccessText] = useState('') + const [invalidLogin, setInvalidLogin] = useState(false) const [allPerms, setAllPerms] = useState({myPerms: null, otherUserPerms: null}) const deleteWidget = useDeleteWidget() const unDeleteWidget = useUnDeleteWidget() const updateWidget = useUpdateWidget() - const copyWidget = useCopyWidget() + + const [alertDialog, setAlertDialog] = useState({ + enabled: false, + message: '', + title: 'Failure', + fatal: false, + enableLoginButton: false + }) const { data: instOwner, isFetching: loadingInstOwner } = useQuery({ queryKey: ['instance-owner', inst.id], @@ -65,9 +73,27 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => { queryFn: () => apiGetUserPermsForInstance(inst.id), enabled: !!inst && inst.id !== undefined, placeholderData: null, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + } }) + // hook associated with the invalidLogin error + useEffect(() => { + if (invalidLogin) { + setAlertDialog({ + enabled: true, + message: 'You must be logged in to edit widgets.', + title: 'Login Required', + fatal: true, + enableLoginButton: true + }) + } + }, [invalidLogin]) + useEffect(() => { if (perms) { const isEditable = inst.widget.is_editable === '1' @@ -89,39 +115,37 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => { setUpdatedInst({...updatedInst, [attr]: value }) } - const makeCopy = (title, copyPerms) => { - setShowCopy(false) - onCopy(updatedInst.id, title, copyPerms, updatedInst) - } - - const onCopy = (instId, title, copyPerms, inst) => { - copyWidget.mutate({ - instId: instId, - title: title, - copyPermissions: copyPerms, - dir: inst.widget.dir, - successFunc: newInst => { - window.location.hash = newInst.id; - } - }) - } - const onDelete = instId => { deleteWidget.mutate({ instId: instId, - successFunc: () => setUpdatedInst({...updatedInst, is_deleted: true}) + successFunc: () => setUpdatedInst({...updatedInst, is_deleted: true}), + errorFunc: (err) => { + setErrorText('Error: Delete Unsuccessful') + setSuccessText('') + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + } }) } const onUndelete = instId => { unDeleteWidget.mutate({ instId: instId, - successFunc: () => setUpdatedInst({...updatedInst, is_deleted: false}) + successFunc: () => setUpdatedInst({...updatedInst, is_deleted: false}), + errorFunc: (err) => { + setErrorText('Error: Undelete Unsuccessful') + setSuccessText('') + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + } }) } const applyChanges = () => { setErrorText('') + setSuccessText('') let u = updatedInst if (!availableDisabled && !closeDisabled) @@ -186,12 +210,23 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => { updateWidget.mutate({ args: args, successFunc: () => { - setSuccessText('Success!') + setSuccessText('Widget Updated Successfully') setErrorText('') }, - errorFunc: () => { + errorFunc: (err) => { setErrorText('Error: Update Unsuccessful') setSuccessText('') + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } else { + setAlertDialog({ + enabled: true, + message: err.cause, + title: err.message, + fatal: err.halt, + enableLoginButton: false + }) + } } }) } @@ -199,9 +234,19 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => { let copyDialogRender = null if (showCopy) { copyDialogRender = ( - setShowCopy(false)} - onCopy={makeCopy} + onCopySuccess={(newInst) => { + setShowCopy(false) + window.location.hash = newInst.id + }} + onCopyError={(err) => { + if (err.message == "Invalid Login") { + setInvalidLogin(true) + } + }} /> ) } @@ -247,212 +292,229 @@ const SupportSelectedInstance = ({inst, currentUser, embed = false}) => {
} - return ( -
- { breadcrumbContainer } -
-
- - handleChange('name', event.target.value)} - /> -
-
- - - - - + let alertDialogRender = null + if (alertDialog.enabled) { + alertDialogRender = ( + { + setAlertDialog({...alertDialog, enabled: false}) + }} /> + ) + } -
-
-
-
- - {updatedInst.id} -
-
- - {loadingInstOwner || instOwner == undefined ? 'Loading...' : `${instOwner[updatedInst.user_id]?.first} ${instOwner[updatedInst.user_id]?.last}`} -
-
- - {(new Date(updatedInst.created_at*1000)).toLocaleString()} -
-
- - {updatedInst.is_draft ? 'Yes' : 'No'} -
-
- - {updatedInst.is_student_made ? 'Yes' : 'No'} -
-
- - -
-
- - {updatedInst.student_access ? 'Yes' : 'No'} -
-
- - -
-
- - {updatedInst.is_embedded ? 'Yes' : 'No'} + return ( + <> + { alertDialogRender } +
+ { breadcrumbContainer } +
+
+ + handleChange('name', event.target.value)} + />
-
- - {updatedInst.is_deleted ? 'Yes' : 'No'} +
+ + + + + +
-
- -
-
- -
- setAvailableDisabled(false)} - /> - - setAvailableDate(event.target.value)} - disabled={availableDisabled} - /> - setAvailableTime(event.target.value)} - disabled={availableDisabled} - /> +
+ {errorText != '' ?

{errorText}

: <> } + {successText != '' ?

{successText}

: <> } +
+ + {updatedInst.id}
-
- {setAvailableDisabled(true); handleChange('open_at', -1)}} - /> - +
+ + {loadingInstOwner || instOwner == undefined ? 'Loading...' : `${instOwner[updatedInst.user_id]?.first} ${instOwner[updatedInst.user_id]?.last}`}
-
-
- -
- setCloseDisabled(false)} - /> - - setCloseDate(event.target.value)} disabled={closeDisabled} - /> - setCloseTime(event.target.value)} disabled={closeDisabled} - /> +
+ + {(new Date(updatedInst.created_at*1000)).toLocaleString()}
-
- {setCloseDisabled(true); handleChange('close_at', -1)}} - /> - +
+ + {updatedInst.is_draft ? 'Yes' : 'No'}
-
- - - -
-
- - {errorText} - {successText} +
+ + {updatedInst.is_student_made ? 'Yes' : 'No'} +
+
+ + +
+
+ + {updatedInst.student_access ? 'Yes' : 'No'} +
+
+ + +
+
+ + {updatedInst.is_embedded ? 'Yes' : 'No'} +
+
+ + {updatedInst.is_deleted ? 'Yes' : 'No'} +
+
+ + +
+
+ +
+ setAvailableDisabled(false)} + /> + + setAvailableDate(event.target.value)} + disabled={availableDisabled} + /> + setAvailableTime(event.target.value)} + disabled={availableDisabled} + /> +
+
+ {setAvailableDisabled(true); handleChange('open_at', -1)}} + /> + +
+
+
+ +
+ setCloseDisabled(false)} + /> + + setCloseDate(event.target.value)} disabled={closeDisabled} + /> + setCloseTime(event.target.value)} disabled={closeDisabled} + /> +
+
+ {setCloseDisabled(true); handleChange('close_at', -1)}} + /> + +
+
+ + + +
+
+ +
-
-
- { copyDialogRender } - { collaborateDialogRender } - { extraAttemptsDialogRender } -
+
+ { copyDialogRender } + { collaborateDialogRender } + { extraAttemptsDialogRender } +
+ ) } diff --git a/src/components/user-admin-instance-available.jsx b/src/components/user-admin-instance-available.jsx index 472e4bab4..6765a3f35 100644 --- a/src/components/user-admin-instance-available.jsx +++ b/src/components/user-admin-instance-available.jsx @@ -1,42 +1,31 @@ import React, { useState } from 'react' import { iconUrl } from '../util/icon-url' import SupportSelectedInstance from './support-selected-instance' -import useCopyWidget from './hooks/useCopyWidget' const UserAdminInstanceAvailable = ({instance, index, currentUser}) => { - - const copyWidget = useCopyWidget() + const [error, setError] = useState('') const [instanceState, setInstanceState] = useState({ expanded: false, manager: false }) - const onCopy = (instId, title, copyPerms, inst) => { - copyWidget.mutate({ - instId: instId, - title: title, - copyPermissions: copyPerms, - dir: inst.widget.dir, - successFunc: (copyId) => { - if (!copyPerms) { - window.location = `/my-widgets#${copyId}` - } - } - }) - } - let managerRender = null if (instanceState.manager) { managerRender = ( ) } + let errorRender = null + if (error) { + errorRender =

{error}

+ } + return (
  • {
    :
    + { errorRender } { managerRender }
    } diff --git a/src/components/user-admin-page.scss b/src/components/user-admin-page.scss index 2082ee035..f854b23b9 100644 --- a/src/components/user-admin-page.scss +++ b/src/components/user-admin-page.scss @@ -379,18 +379,6 @@ .apply { padding: 6px 10px 6px 10px; } - - .error-text { - color: red; - font-size: 0.8em; - text-align: center; - } - - .success-text { - color: green; - font-size: 0.8em; - text-align: center; - } } } } diff --git a/src/components/user-admin-role-manager.jsx b/src/components/user-admin-role-manager.jsx index 011482148..d208148fb 100644 --- a/src/components/user-admin-role-manager.jsx +++ b/src/components/user-admin-role-manager.jsx @@ -39,6 +39,12 @@ const UserAdminRoleManager = ({currentUser, selectedUser}) => { status: response.success ? 'Successful.' : 'Error.', message: response.status }) + }, + errorFunc: (err) => { + setUpdateStatus({ + status: 'Error.', + message: err.message + }) } }) } diff --git a/src/components/widget-admin-install.jsx b/src/components/widget-admin-install.jsx index 7baa98e02..d45e4f627 100644 --- a/src/components/widget-admin-install.jsx +++ b/src/components/widget-admin-install.jsx @@ -19,7 +19,7 @@ const WidgetInstall = ({refetchWidgets}) => { herokuWarning: window.HEROKU_WARNING }) }, [window.UPLOAD_ENABLED, window.ACTION_LINK, window.HEROKU_WARNING]) - + const handleChange = async (event) => { const files = event.target.files let correctFileExtension = true; @@ -34,19 +34,17 @@ const WidgetInstall = ({refetchWidgets}) => { }) if (correctFileExtension) apiUploadWidgets(files) - .then((response) => { - if (response.ok && response.status !== 204 && response.status < 400) { - setState({...state, uploadNotice: `Successfully uploaded '${files[0].name}'!`, isUploading: false, uploadError: false}) - refetchWidgets() - } else { - setState({...state, uploadNotice: `Failed to upload '${files[0].name}'`, isUploading: false, uploadError: true}) - } + .then(res => { + setState({...state, uploadNotice: `Successfully uploaded '${files[0].name}'!`, isUploading: false, uploadError: false}) + refetchWidgets() + }).catch(err => { + setState({...state, uploadNotice: `Failed to upload '${files[0].name}'`, isUploading: false, uploadError: true}) }) } let herokuWarning = null if (state.herokuWarning) { - herokuWarning = + herokuWarning =

    Note: On Heroku, installing widgets must happen during the Heroku build process. Read more at {

    - { state.uploadNotice } +

    { state.uploadNotice }

    Browse installable widgets on The Official Materia Widget Gallery

    Browse features and more on The Official Materia Documentation Page

    diff --git a/src/components/widget-admin-list-card.jsx b/src/components/widget-admin-list-card.jsx index ac3c8dd34..28417c5ea 100644 --- a/src/components/widget-admin-list-card.jsx +++ b/src/components/widget-admin-list-card.jsx @@ -19,16 +19,6 @@ const WidgetListCard = ({widget = null}) => { }}) }, [widget]) - // Timeout function for success message upon saving widget - useEffect(() => { - if (state.success) - { - setTimeout(() => { - setState(prevState => ({...prevState, success: false})) - }, 3000) - } - }, [state.success]) - const handleWidgetClick = () => { setState(prevState => ({...prevState, widget: {...prevState.widget, expanded: !prevState.widget.expanded}, success: false, errorMessage: ''})) } @@ -37,7 +27,7 @@ const WidgetListCard = ({widget = null}) => { event.persist() setState(prevState => ({...prevState, widget: {...prevState.widget, meta_data: {...prevState.widget.meta_data, demo: event.target.value}}})) } - + const handleAboutChange = event => { event.persist() setState(prevState => ({...prevState, widget: {...prevState.widget, meta_data: {...prevState.widget.meta_data, about: event.target.value}}})) @@ -69,6 +59,8 @@ const WidgetListCard = ({widget = null}) => { } const saveWidget = () => { + setState(prevState => ({...prevState, success: false})) + const update = { id: state.widget.id, clean_name: state.widget.clean_name, @@ -81,7 +73,7 @@ const WidgetListCard = ({widget = null}) => { excerpt: state.widget.meta_data.excerpt, demo: state.widget.meta_data.demo, } - + apiUpdateWidgetAdmin(update).then(response => { let errorMessage = [] let success = false @@ -100,17 +92,21 @@ const WidgetListCard = ({widget = null}) => { else success = true } setState(prevState => ({...prevState, errorMessage: errorMessage, success: success})) + }).catch(err => { + setState(prevState => ({...prevState, errorMessage: [err], success: false})) }) } let widgetErrorsRender = null if (state.errorMessage) { - widgetErrorsRender = state.errorMessage.map((error, i) =>
    {error}
    ) + widgetErrorsRender = state.errorMessage.map((error, i) =>

    {error}

    ) } let widgetSuccessRender = null if (state.success) { - widgetSuccessRender =
    Widget Saved!
    + widgetSuccessRender =
    +

    Widget Saved!

    +
    } let featuresRender = null @@ -136,7 +132,7 @@ const WidgetListCard = ({widget = null}) => { {state.widget.name}
  • - { ! state.widget.expanded ? <> : + { ! state.widget.expanded ? <> :
    { widgetErrorsRender } { widgetSuccessRender } @@ -201,7 +197,7 @@ const WidgetListCard = ({widget = null}) => {
    -
    diff --git a/src/components/widget-creator.jsx b/src/components/widget-creator.jsx index 34cd8a91e..8d81004fd 100644 --- a/src/components/widget-creator.jsx +++ b/src/components/widget-creator.jsx @@ -26,7 +26,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorPath: null, dialogPath: null, dialogType: 'embed_dialog', - hearbeatEnabled: true, + heartbeatEnabled: true, hasCreatorGuide: false, creatorGuideUrl: window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')) + '/creators-guide', showActionBar: true, @@ -71,10 +71,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidget(widgetId), enabled: !!widgetId, staleTime: Infinity, - onSettled: (info) => { + onSuccess: (info) => { if (info) { setInstance({ ...instance, widget: info }) } + }, + onError: (error) => { + onInitFail(error) } }) @@ -85,12 +88,15 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidgetInstance(instId), enabled: !!instId, staleTime: Infinity, - onSettled: (data) => { + onSuccess: (data) => { // this value will include a qset that's always empty // it will override the instance's qset property even if it's already set // remove it so the existing qset data isn't overwritten if (data.qset) delete data.qset setInstance({ ...instance, ...data }) + }, + onError: (error) => { + onInitFail(error) } }) @@ -102,14 +108,14 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { staleTime: Infinity, placeholderData: null, enabled: !!instance.id, // requires instance state object to be prepopulated - onSettled: (data) => { - if ( (data != null ? data.title : undefined) === 'Permission Denied' || (data && data.title === 'error')) { - setCreatorState({...creatorState, invalid: true}) - onInitFail('Permission Denied') - } else { + onSuccess: (data) => { + if (data) { setCreatorState({...creatorState, invalid: false}) setInstance({ ...instance, qset: data }) } + }, + onError: (error) => { + onInitFail(error) } }) @@ -120,10 +126,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiCanBePublishedByCurrentUser(instance.widget?.id), enabled: instance?.widget !== null, staleTime: Infinity, - onSettled: (success) => { + onSuccess: (success) => { if (!success && !instance.is_draft) { onInitFail('Widget type can not be edited by students after publishing.') } + }, + onError: (error) => { + onInitFail(error) } }) @@ -132,10 +141,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiAuthorVerify(), staleTime: 30000, refetchInterval: 30000, - enabled: creatorState.hearbeatEnabled, - onSettled: (valid) => { - if (!valid) { - setCreatorState({...creatorState, hearbeatEnabled: false}) + enabled: creatorState.heartbeatEnabled, + onError: (error) => { + onInitFail(error) + }, + onSuccess: (data) => { + if (!data) { + setCreatorState({...creatorState, invalid: true, heartbeatEnabled: false}) setAlertDialog({ enabled: true, title: 'Invalid Login', message:'You are no longer logged in, please login again to continue.', fatal: true, enableLoginButton: true }) } } @@ -148,10 +160,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidgetLock(instance.id), enabled: !!instance.id, staleTime: Infinity, - onSettled: (success) => { - if (!success) { - onInitFail('Someone else is editing this widget, you will be able to edit after they finish.') - } + onSuccess: (success) => { + if (!success) { + onInitFail('Someone else is editing this widget, you will be able to edit after they finish.') + } + }, + onError: (error) => { + onInitFail(error) } }) @@ -379,9 +394,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } apiSaveWidget(newWidget).then((inst) => { - if ((inst != null ? inst.msg : undefined) != null) { - setAlertDialog({...alertDialog, fatal: inst.halt, enabled: true}) - } else if (inst != null && inst.id != null) { + if (inst != null && inst.id != null) { if (String(instIdRef.current).length !== 0) { window.location.hash = `#${inst.id}` } @@ -417,6 +430,8 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { break } } + }).catch(err => { + onInitFail(err) }) } @@ -612,12 +627,20 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { ) } - const onInitFail = (message) => { - setCreatorState({ - ...creatorState, - invalid: true - }) - setAlertDialog({ enabled: true, title: 'Failure', message: message, fatal: true, enableLoginButton: true }) + const onInitFail = (err) => { + if (err.message == "Invalid Login") { + setCreatorState({...creatorState, invalid: true, heartbeatEnabled: false}) + setAlertDialog({ enabled: true, title: 'Invalid Login', message:'You are no longer logged in, please login again to continue.', fatal: true, enableLoginButton: true }) + } else if (err.message == "Permission Denied") { + setCreatorState({ + ...creatorState, + invalid: true + }) + } else { + setAlertDialog( + { enabled: true, title: err.message, msg: err.cause, fatal: err.halt, enableLoginButton: false } + ) + } } const onPublishPressed = () => { @@ -771,29 +794,31 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } return ( -
    -
    - { alertDialogRender } - { popupRender } - { actionBarRender } - { rollbackConfirmBarRender } -
    - -
    - { noPermissionRender } -
    + <> + { alertDialogRender } + { noPermissionRender ? noPermissionRender : +
    +
    + { popupRender } + { actionBarRender } + { rollbackConfirmBarRender } +
    + +
    +
    } + ) } diff --git a/src/components/widget-player.jsx b/src/components/widget-player.jsx index cff1cd89f..559ac8ddf 100644 --- a/src/components/widget-player.jsx +++ b/src/components/widget-player.jsx @@ -111,7 +111,6 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= const savePlayLog = usePlayLogSave() const saveStorage = usePlayStorageDataSave() - // refs are used instead of state when value updates do not require a component rerender const centerRef = useRef(null) @@ -123,14 +122,50 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= queryKey: ['widget-inst', instanceId], queryFn: () => apiGetWidgetInstance(instanceId), enabled: instanceId !== null, - staleTime: Infinity + staleTime: Infinity, + onError: (err) => { + if (err.message == "Invalid Login") { + setAlert({ + msg: "You are no longer logged in.", + title: 'Invalid Play Session', + fatal: true, + showLoginButton: true + }) + } else if (err.message == "Permission Denied") { + setAlert({ + msg: "You do not have permission to view this widget.", + title: 'Failure', + fatal: err.halt, + showLoginButton: false + }) + } + else _onLoadFail("There was a problem loading the widget instance.") + } }) const { data: qset } = useQuery({ queryKey: ['qset', instanceId], queryFn: () => apiGetQuestionSet(instanceId, playId), staleTime: Infinity, - placeholderData: null + placeholderData: null, + onError: (err) => { + if (err.message == "Invalid Login") { + setAlert({ + msg: "You are no longer logged in.", + title: 'Invalid Play Session', + fatal: true, + showLoginButton: true + }) + } else if (err.message == "Permission Denied") { + setAlert({ + msg: "You do not have permission to view this widget.", + title: 'Failure', + fatal: err.halt, + showLoginButton: false + }) + } + else _onLoadFail("There was a problem loading the widget's question set.") + } }) const { data: heartbeat } = useQuery({ @@ -139,14 +174,21 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= staleTime: Infinity, refetchInterval: HEARTBEAT_INTERVAL, enabled: !!playId && heartbeatActive, - onSettled: (result) => { - if (result != true) { + onError: (err) => { + if (err.message == "Invalid Login") { setAlert({ - msg: "Your play session is no longer valid. You'll need to reload the page and start over.", + msg: "You are no longer logged in.", title: 'Invalid Play Session', - fatal: true + fatal: true, + showLoginButton: true }) } + else _onLoadFail("Your play session is no longer valid. You'll need to reload the page and start over.") + }, + onSuccess: (data) => { + if (!data) { + _onLoadFail("Your play session is no longer valid. You'll need to reload the page and start over.") + } } }) @@ -391,20 +433,19 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= if (result.score_url) { // score_url is sent from server to redirect to a specific url setScoreScreenURL(result.score_url) - } else if (result.type === 'error') { - - setAlert({ - title: 'We encountered a problem', - msg: result.msg || 'An error occurred when saving play logs', - fatal: true - }) } } if (logQueue.length > 0) _pushPendingLogs(logQueue) else setQueueProcessing(false) }, - failureFunc: () => { + errorFunc: (err) => { + setAlert({ + title: 'We encountered a problem', + msg: 'An error occurred when saving play logs', + fatal: err.halt + }) + setRetryCount((oldCount) => { let retrySpeed = player.RETRY_FAST @@ -414,7 +455,7 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= setAlert({ title: 'We encountered a problem', msg: 'Connection to the Materia server was lost. Check your connection or reload to start over.', - fatal: false + fatal: err.halt }) } @@ -454,6 +495,13 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= fatal: false }) } + }, + errorFunc: (err) => { + setAlert({ + msg: 'There was an issue saving storage data. Check your connection or reload to start over.', + title: 'We ran into a problem', + fatal: err.halt + }) } }) } @@ -483,7 +531,8 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= const _onLoadFail = msg => setAlert({ msg: msg, title: 'Failure!', - fatal: true + fatal: true, + showLoginButton: false }) const _beforeUnload = e => { @@ -526,9 +575,9 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= msg={alert.msg} title={alert.title} fatal={alert.fatal} - showLoginButton={false} + showLoginButton={alert.showLoginButton} onCloseCallback={() => { - setAlert({msg: '', title: '', fatal: false}) + setAlert({msg: '', title: '', fatal: false, showLoginButton: false}) }} /> ) } @@ -542,26 +591,28 @@ const WidgetPlayer = ({instanceId, playId, minHeight='', minWidth='',showFooter= } return ( -
    - { previewBarRender } -
    - { alertDialogRender } -