diff --git a/fuel/app/classes/controller/api/admin.php b/fuel/app/classes/controller/api/admin.php index df6e1bb2b..9f3b080f8 100644 --- a/fuel/app/classes/controller/api/admin.php +++ b/fuel/app/classes/controller/api/admin.php @@ -69,13 +69,20 @@ public function post_user($user_id) return \Service_User::update_user($user_id, $user); } - public function get_widget_search(string $input) + public function get_instance_search(string $input, string $page_number) { $input = trim($input); $input = urldecode($input); + $page_number = (int) $page_number; //no need to search if for some reason an empty string is passed - if ($input == '') return []; - return \Materia\Widget_Instance_Manager::get_search($input); + if ($input == '') + { + return [ + 'pagination' => [], + 'next_page' => $page_number + ]; + } + return \Materia\Widget_Instance_Manager::get_paginated_instance_search($input, $page_number); } public function get_extra_attempts(string $inst_id) diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index 3e0218a23..8fc26b70d 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -64,10 +64,10 @@ static public function widget_instances_get($inst_ids = null, bool $deleted = fa * * @return array of objects containing total_num_pages and widget instances that are visible to the user. */ - static public function widget_paginate_instances_get($page_number = 0) + static public function widget_paginate_user_instances_get($page_number = 0) { if (\Service_User::verify_session() !== true) return Msg::no_login(); - $data = Widget_Instance_Manager::get_paginated_for_user(\Model_User::find_current_id(), $page_number); + $data = Widget_Instance_Manager::get_paginated_instances_for_user(\Model_User::find_current_id(), $page_number); return $data; } @@ -886,23 +886,39 @@ static public function semester_date_ranges_get() return Utils::get_date_ranges(); } - static public function users_search($search) + /** + * Paginated search for users that match input + * + * @param string Search query + * @param string Page number + * @return array List of users + */ + static public function users_search($input, $page_number = 0) { if (\Service_User::verify_session() !== true) return Msg::no_login(); - $user_objects = \Model_User::find_by_name_search($search); - $user_arrays = []; + $items_per_page = 50; + $offset = $items_per_page * $page_number; + + // query DB for only a single page + 1 item + $displayable_items = \Model_User::find_by_name_search($input, $offset, $items_per_page + 1); + + $has_next_page = sizeof($displayable_items) > $items_per_page ? true : false; + + if ($has_next_page) array_pop($displayable_items); - // scrub the user models with to_array - if (count($user_objects)) + foreach ($displayable_items as $key => $person) { - foreach ($user_objects as $key => $person) - { - $user_arrays[$key] = $person->to_array(); - } + $displayable_items[$key] = $person->to_array(); } - return $user_arrays; + $data = [ + 'pagination' => $displayable_items, + ]; + + if ($has_next_page) $data['next_page'] = $page_number + 1; + + return $data; } /** * Gets information about the current user diff --git a/fuel/app/classes/materia/widget/instance/manager.php b/fuel/app/classes/materia/widget/instance/manager.php index ff345715f..65f7251c1 100644 --- a/fuel/app/classes/materia/widget/instance/manager.php +++ b/fuel/app/classes/materia/widget/instance/manager.php @@ -14,7 +14,7 @@ static public function get($inst_id, $load_qset=false, $timestamp=false, $delete return count($instances) > 0 ? $instances[0] : false; } - static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=false, bool $deleted=false): array + static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=false, bool $deleted=false, $offset=0, $limit=80): array { if ( ! is_array($inst_ids) || count($inst_ids) < 1) return []; @@ -27,6 +27,9 @@ static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=fal ->where('id', 'IN', $inst_ids) ->and_where('is_deleted', '=', $deleted ? '1' : '0') ->order_by('created_at', 'desc') + ->order_by('id', 'desc') + ->offset("$offset") + ->limit("$limit") ->execute() ->as_array(); @@ -63,7 +66,7 @@ public static function get_all_for_user($user_id, $load_qset=false) { $inst_ids = Perm_Manager::get_all_objects_for_user($user_id, Perm::INSTANCE, [Perm::FULL, Perm::VISIBLE]); - if ( ! empty($inst_ids)) return Widget_Instance_Manager::get_all($inst_ids, $load_qset); + if ( ! empty($inst_ids)) return self::get_all($inst_ids, $load_qset); else return []; } @@ -76,22 +79,25 @@ public static function get_all_for_user($user_id, $load_qset=false) * * @return array of widget instances that are visible to the user. */ - public static function get_paginated_for_user($user_id, $page_number = 0) + public static function get_paginated_instances_for_user($user_id, $page_number = 0) { $inst_ids = Perm_Manager::get_all_objects_for_user($user_id, Perm::INSTANCE, [Perm::FULL, Perm::VISIBLE]); - $displayable_inst = self::get_all($inst_ids); - $widgets_per_page = 80; - $total_num_pages = ceil(sizeof($displayable_inst) / $widgets_per_page); - $offset = $widgets_per_page * $page_number; - $has_next_page = $offset + $widgets_per_page < sizeof($displayable_inst) ? true : false; - // inst_ids corresponds to a single page's worth of instances - $displayable_inst = array_slice($displayable_inst, $offset, $widgets_per_page); + $items_per_page = 80; + $offset = $items_per_page * $page_number; + + // query DB for only a single page of instances + 1 + $displayable_items = self::get_all($inst_ids, false, false, false, $offset, $items_per_page + 1); + + // if the returned number of instances is greater than a page, there's more pages + $has_next_page = sizeof($displayable_items) > $items_per_page ? true : false; + + if ($has_next_page) array_pop($displayable_items); $data = [ - 'pagination' => $displayable_inst + 'pagination' => $displayable_items ]; - + if ($has_next_page) $data['next_page'] = $page_number + 1; return $data; @@ -135,20 +141,55 @@ public static function lock($inst_id) return $locked_by == $me; } + /** + * Widget instance paginated search results + * + * @param input search input + * @param page_number page number + * + * @return array of items related to the given input + */ + public static function get_paginated_instance_search(string $input, $page_number = 0) + { + $items_per_page = 80; + $offset = $items_per_page * $page_number; + + // query DB for only a single page of instances + 1 + $displayable_items = self::get_widget_instance_search($input, $offset, $items_per_page + 1); + + // if the returned number of instances is greater than a page, there's more pages + $has_next_page = sizeof($displayable_items) > $items_per_page ? true : false; + + if ($has_next_page) array_pop($displayable_items); + + $data = [ + 'pagination' => $displayable_items, + ]; + + if ($has_next_page) $data['next_page'] = $page_number + 1; + + return $data; + } + /** * Gets all widget instances related to a given input, including id or name. * * @param input search input + * @param offset start search at this row in results + * @param limit number of rows to include * * @return array of widget instances related to the given input */ - public static function get_search(string $input): array + public static function get_widget_instance_search(string $input, int $offset = 0, int $limit = 80): array { $results = \DB::select() ->from('widget_instance') ->where('id', 'LIKE', "%$input%") ->or_where('name', 'LIKE', "%$input%") ->order_by('created_at', 'desc') + ->order_by('id', 'desc') + ->offset($offset) + ->limit($limit) ->execute() ->as_array(); diff --git a/fuel/app/classes/model/user.php b/fuel/app/classes/model/user.php index c29874777..122456326 100644 --- a/fuel/app/classes/model/user.php +++ b/fuel/app/classes/model/user.php @@ -87,7 +87,7 @@ public static function find_by_username($username) ->get_one(); } - static public function find_by_name_search($name) + static public function find_by_name_search($name, $offset = 0, $limit=80) { $name = preg_replace('/\s+/', '', $name); // remove spaces @@ -108,11 +108,19 @@ static public function find_by_name_search($name) ->or_where(\DB::expr('REPLACE(CONCAT(first, last), " ", "")'), 'LIKE', "%$name%") ->or_where('email', 'LIKE', "$name%") ->and_where_close() - ->limit(50) + ->offset($offset) + ->limit($limit) ->as_object('Model_User') ->execute(); - return $matches; + // convert object to array + $list = []; + foreach ($matches as $match) + { + $list[] = $match; + } + + return $list; } public static function validate($factory) diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index 42b524c6d..2300c9529 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -180,9 +180,32 @@ public function test_widget_instances_get() } - public function test_widget_paginate_instances_get() + public function test_widget_paginate_user_instances_get() { + // Create widget instance + $this->_as_author(); + $title = "My Test Widget"; + $question = 'What rhymes with harvest fests but are half as exciting (or tasty)'; + $answer = 'Tests'; + $qset = $this->create_new_qset($question, $answer); + $widget = $this->make_disposable_widget(); + + $instance = Api_V1::widget_instance_new($widget->id, $title, $qset, true); + + // ----- loads author's instances -------- + $output = Api_V1::widget_paginate_user_instances_get(); + $this->assertIsArray($output); + $this->assertArrayHasKey('pagination', $output); + foreach ($output['pagination'] as $key => $value) + { + $this->assert_is_widget_instance($value, true); + } + // ======= AS NO ONE ======== + \Auth::logout(); + // ----- returns no login -------- + $output = Api_V1::widget_paginate_user_instances_get(); + $this->assert_invalid_login_message($output); } public function test_widget_instance_new() @@ -1024,10 +1047,6 @@ public function test_play_logs_get() } - public function test_paginated_play_logs_get() - { - } - public function test_score_summary_get() { // ======= AS NO ONE ======== @@ -1522,10 +1541,10 @@ public function test_users_search_as_student() $output = Api_V1::users_search('droptables'); $this->assertIsArray($output); - $this->assertCount(2, $output); - $this->assert_is_user_array($output[0]); - $this->assertFalse(array_key_exists('password', $output)); - $this->assertFalse(array_key_exists('login_hash', $output)); + $this->assertIsArray($output['pagination']); + $this->assert_is_user_array($output['pagination'][0]); + $this->assertFalse(array_key_exists('password', $output['pagination'])); + $this->assertFalse(array_key_exists('login_hash', $output['pagination'])); } public function test_users_search_as_author() @@ -1538,10 +1557,10 @@ public function test_users_search_as_author() $output = Api_V1::users_search('droptables'); $this->assertIsArray($output); - $this->assertCount(2, $output); - $this->assert_is_user_array($output[0]); - $this->assertFalse(array_key_exists('password', $output)); - $this->assertFalse(array_key_exists('login_hash', $output)); + $this->assertIsArray($output['pagination']); + $this->assert_is_user_array($output['pagination'][0]); + $this->assertFalse(array_key_exists('password', $output['pagination'])); + $this->assertFalse(array_key_exists('login_hash', $output['pagination'])); } public function test_users_search_as_super_user() @@ -1554,10 +1573,10 @@ public function test_users_search_as_super_user() $output = Api_V1::users_search('droptables'); $this->assertIsArray($output); - $this->assertCount(2, $output); - $this->assert_is_user_array($output[0]); - $this->assertFalse(array_key_exists('password', $output[0])); - $this->assertFalse(array_key_exists('login_hash', $output[0])); + $this->assertIsArray($output['pagination']); + $this->assert_is_user_array($output['pagination'][0]); + $this->assertFalse(array_key_exists('password', $output['pagination'])); + $this->assertFalse(array_key_exists('login_hash', $output['pagination'])); } protected function assert_is_semester_rage($semester) diff --git a/fuel/app/tests/model/user.php b/fuel/app/tests/model/user.php index 4cd42d0c4..7d7c13c2e 100644 --- a/fuel/app/tests/model/user.php +++ b/fuel/app/tests/model/user.php @@ -15,12 +15,12 @@ public function test_find_by_name_search_doesnt_find_super_users() { // su should't be found $su = $this->make_random_super_user(); - $x = Model_User::find_by_name_search($su->email)->as_array(); + $x = Model_User::find_by_name_search($su->email); self::assertEmpty($x); // add a student with the same name, should only find the one student $this->make_random_student(); - $x = Model_User::find_by_name_search('drop')->as_array(); + $x = Model_User::find_by_name_search('drop'); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertNotEquals($su->id, $x[0]->id); @@ -30,7 +30,7 @@ public function test_find_by_name_search_finds_students_by_email() { $user = $this->make_random_student(); - $x = Model_User::find_by_name_search($user->email)->as_array(); + $x = Model_User::find_by_name_search($user->email); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -40,7 +40,7 @@ public function test_find_by_name_search_finds_students_by_first_name() { $user = $this->make_random_student(); - $x = Model_User::find_by_name_search($user->first)->as_array(); + $x = Model_User::find_by_name_search($user->first); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -50,7 +50,7 @@ public function test_find_by_name_search_finds_students_by_last_name() { $user = $this->make_random_student(); - $x = Model_User::find_by_name_search($user->last)->as_array(); + $x = Model_User::find_by_name_search($user->last); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -60,7 +60,7 @@ public function test_find_by_name_search_finds_students_by_username() { $user = $this->make_random_student(); - $x = Model_User::find_by_name_search($user->username)->as_array(); + $x = Model_User::find_by_name_search($user->username); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -70,7 +70,7 @@ public function test_find_by_name_search_finds_students() { $user = $this->make_random_student(); - $x = Model_User::find_by_name_search($user->email)->as_array(); + $x = Model_User::find_by_name_search($user->email); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -79,7 +79,7 @@ public function test_find_by_name_search_finds_students() public function test_find_by_name_search_finds_authors() { $user = $this->make_random_author(); - $x = Model_User::find_by_name_search($user->email)->as_array(); + $x = Model_User::find_by_name_search($user->email); self::assertCount(1, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertEquals($user->id, $x[0]->id); @@ -90,7 +90,7 @@ public function test_find_by_name_search_finds_multiple_matches() $user1 = $this->make_random_author(); $user2 = $this->make_random_student(); - $x = Model_User::find_by_name_search('drop')->as_array(); + $x = Model_User::find_by_name_search('drop'); self::assertCount(2, $x); self::assertInstanceOf('Model_User', $x[0]); self::assertInstanceOf('Model_User', $x[1]); diff --git a/src/components/extra-attempts-dialog.scss b/src/components/extra-attempts-dialog.scss index 28fc47225..7f8482b4b 100644 --- a/src/components/extra-attempts-dialog.scss +++ b/src/components/extra-attempts-dialog.scss @@ -37,7 +37,7 @@ } .attempts_search_list { - width: 447px; + width: 449px; position: absolute; background-color: #ffffff; border: #bfbfbf 1px solid; @@ -45,7 +45,8 @@ overflow: auto; z-index: 3; text-align: left; - left: 114px; + left: 121px; + top: 111px; display: flex; flex-wrap: wrap; align-items: flex-start; diff --git a/src/components/hooks/useInstanceList.jsx b/src/components/hooks/useInstanceList.jsx index 41577b6d2..3254ea43c 100644 --- a/src/components/hooks/useInstanceList.jsx +++ b/src/components/hooks/useInstanceList.jsx @@ -1,15 +1,12 @@ import { useState, useEffect, useMemo } from 'react' import { useInfiniteQuery } from 'react-query' -import { apiGetWidgetInstances } from '../../util/api' +import { apiGetUserWidgetInstances } from '../../util/api' import { iconUrl } from '../../util/icon-url' export default function useInstanceList() { const [errorState, setErrorState] = useState(false) - // Helper function to sort widgets - const _compareWidgets = (a, b) => { return (b.created_at - a.created_at) } - // transforms data object returned from infinite query into one we can use in the my-widgets-page component // this creates a flat list of instances from the paginated list that's subsequently sorted const formatData = (list) => { @@ -31,12 +28,12 @@ export default function useInstanceList() { } })) ) - ].sort(_compareWidgets) + ] } else return [] } - const getWidgetInstances = ({ pageParam = 0}) => { - return apiGetWidgetInstances(pageParam) + const getWidgetInstances = ({ pageParam = 0 }) => { + return apiGetUserWidgetInstances(pageParam) } const { diff --git a/src/components/hooks/useSearchInstances.jsx b/src/components/hooks/useSearchInstances.jsx new file mode 100644 index 000000000..406533739 --- /dev/null +++ b/src/components/hooks/useSearchInstances.jsx @@ -0,0 +1,71 @@ +import { useState, useEffect, useMemo } from 'react' +import { useInfiniteQuery } from 'react-query' +import { apiSearchInstances } from '../../util/api' +import { iconUrl } from '../../util/icon-url' + +export default function useSearchInstances(query = "") { + + const [errorState, setErrorState] = useState(false) + + // transforms data object returned from infinite query + const formatData = (list) => { + if (list?.type == 'error') { + console.error(`Widget instances failed to load with error: ${list.msg}`); + setErrorState(true) + return [] + } + if (list?.pages) { + let dataMap = [] + return [ + ...dataMap.concat( + ...list.pages.map(page => page.pagination.map(instance => { + // adding an 'img' property to widget instance objects + return { + ...instance, + img: iconUrl(BASE_URL + 'widget/', instance.widget.dir, 275) + } + })) + ) + ] + } else return [] + } + + const getWidgetInstances = ({ pageParam = 0 }) => { + return apiSearchInstances(query, pageParam) + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + refetch + } = useInfiniteQuery({ + queryKey: ['searched_instances', query], + queryFn: getWidgetInstances, + enabled: query.length > 0, + getNextPageParam: (lastPage, pages) => lastPage.next_page, + refetchOnWindowFocus: false + }) + + useEffect(() => { + if (error != null && error != undefined) setErrorState(true) + },[error]) + + // memoize the instance list since this is a large, expensive query + const instances = useMemo(() => formatData(data), [data]) + + useEffect(() => { + if (hasNextPage) fetchNextPage() + },[instances]) + + return { + instances: instances, + isFetching: isFetching || hasNextPage, + refresh: () => refetch(), + ...(errorState == true ? {error: true} : {}) // the error value is only provided if errorState is true + } +} diff --git a/src/components/hooks/useUserList.jsx b/src/components/hooks/useUserList.jsx new file mode 100644 index 000000000..c82af878e --- /dev/null +++ b/src/components/hooks/useUserList.jsx @@ -0,0 +1,67 @@ +import { useState, useEffect, useMemo } from 'react' +import { useInfiniteQuery } from 'react-query' +import { apiSearchUsers } from '../../util/api' + +export default function useUserList(query = "") { + + const [errorState, setErrorState] = useState(false) + + // this creates a flat list of users from the paginated list + const formatData = (list) => { + if (list?.type == 'error') { + console.error(`Users failed to load with error: ${list.msg}`); + setErrorState(true) + return [] + } + if (list?.pages) { + let dataMap = [] + list.pages.forEach(page => { + dataMap.push(...page.pagination) + }) + return dataMap + } + + return [] + } + + const getData = ({ pageParam = 0 }) => { + return apiSearchUsers(query, pageParam) + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + refetch + } = useInfiniteQuery({ + queryKey: ['users', query], + queryFn: getData, + enabled: query.length > 0, + getNextPageParam: (lastPage, pages) => { + return lastPage.next_page + }, + refetchOnWindowFocus: false + }) + + useEffect(() => { + if (error != null && error != undefined) setErrorState(true) + },[error]) + + // memoize the user list since this is a large, expensive query + const users = useMemo(() => formatData(data), [data]) + + useEffect(() => { + if (hasNextPage) fetchNextPage() + },[users]) + + return { + users: users, + isFetching: isFetching || hasNextPage, + refresh: () => refetch(), + ...(errorState == true ? {error: true} : {}) // the error value is only provided if errorState is true + } +} diff --git a/src/components/include.scss b/src/components/include.scss index 5f8cc04b0..ccf11801a 100644 --- a/src/components/include.scss +++ b/src/components/include.scss @@ -295,7 +295,7 @@ header { background-color: #ffffff; padding: 0; position: absolute; - bottom: -150%; + bottom: -140%; left: -10px; border-left: 1px solid #d3d3d3; border-right: 1px solid #d3d3d3; diff --git a/src/components/loading-icon.jsx b/src/components/loading-icon.jsx index 9085c5ea3..4a0ec1f98 100644 --- a/src/components/loading-icon.jsx +++ b/src/components/loading-icon.jsx @@ -1,10 +1,10 @@ import React from 'react' import './loading-icon.scss' -const LoadingIcon = ({size='med', width='100%', top= '0', left='0'}) => { +const LoadingIcon = ({size='med', width='100%', top= '0', left='0', position='absolute'}) => { // Supported sizes: sm, med, lrg return ( -