From a18d7ea4d3498efd6523d4d5f76e5d5f0abb2a31 Mon Sep 17 00:00:00 2001 From: Corey Peterson Date: Mon, 12 Jun 2023 16:24:16 -0400 Subject: [PATCH 1/4] Major rework of instance pagination in my-widgets --- fuel/app/classes/materia/api/v1.php | 8 +- fuel/app/classes/materia/widget/instance.php | 10 +- .../materia/widget/instance/manager.php | 11 +- fuel/app/tests/api/v1.php | 8 +- src/components/hooks/useCopyWidget.jsx | 33 +++-- src/components/hooks/useInstanceList.jsx | 61 ++++++++++ src/components/my-widgets-page.jsx | 113 ++++-------------- src/util/api.js | 2 +- 8 files changed, 132 insertions(+), 114 deletions(-) create mode 100644 src/components/hooks/useInstanceList.jsx diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index b6e1050cc..6ecdfa7fd 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -60,13 +60,13 @@ static public function widget_instances_get($inst_ids = null, bool $deleted = fa * Takes a page number, and returns objects containing the total_num_pages and * widget instances that are visible to the user. * - * @param page_number The page to be retreated. By default it is set to 1. + * @param page_number The page to be requested. By default it is set to 1. * * @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 = 1) + static public function widget_paginate_instances_get($page_number = 0) { - if (\Service_User::verify_session() !== true) return []; // shortcut to returning noting + 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); return $data; } @@ -146,7 +146,7 @@ static public function widget_instance_copy(string $inst_id, string $new_name, b // retain access - if true, grant access to the copy to all original owners $current_user_id = \Model_User::find_current_id(); $duplicate = $inst->duplicate($current_user_id, $new_name, $copy_existing_perms); - return $duplicate->id; + return $duplicate; } catch (\Exception $e) { diff --git a/fuel/app/classes/materia/widget/instance.php b/fuel/app/classes/materia/widget/instance.php index 517a6cf73..742860960 100644 --- a/fuel/app/classes/materia/widget/instance.php +++ b/fuel/app/classes/materia/widget/instance.php @@ -464,7 +464,15 @@ public function duplicate(int $owner_id, string $new_name = null, bool $copy_exi $duplicate->id = 0; // mark as a new game $duplicate->user_id = $owner_id; // set current user as owner in instance table - if ( ! empty($new_name)) $duplicate->name = $new_name; // update name + // update name + if ( ! empty($new_name)) $duplicate->name = $new_name; + + // these values aren't saved to the db - but the frontend will make use of them + $duplicate->clean_name = \Inflector::friendly_title($duplicate->name, '-', true); + $base_url = "{$duplicate->id}/{$duplicate->clean_name}"; + $duplicate->preview_url = \Config::get('materia.urls.preview').$base_url; + $duplicate->play_url = $duplicate->is_draft === false ? \Config::get('materia.urls.play').$base_url : ''; + $duplicate->embed_url = $duplicate->is_draft === false ? \Config::get('materia.urls.embed').$base_url : ''; // if original widget is student made - verify if new owner is a student or not // if they have a basic_author role or above, turn off the is_student_made flag diff --git a/fuel/app/classes/materia/widget/instance/manager.php b/fuel/app/classes/materia/widget/instance/manager.php index 2e564a39f..d6ef5f140 100644 --- a/fuel/app/classes/materia/widget/instance/manager.php +++ b/fuel/app/classes/materia/widget/instance/manager.php @@ -76,20 +76,23 @@ 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 = 1) + public static function get_paginated_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 - 1); + $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); + $data = [ - 'total_num_pages' => $total_num_pages, - 'pagination' => $displayable_inst, + 'pagination' => $displayable_inst, ]; + + if ($has_next_page) $data['next_page'] = $page_number + 1; return $data; } diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index dbb9ab77e..42b524c6d 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -557,9 +557,9 @@ public function test_widget_instance_copy() $output = Api_V1::widget_instance_copy($inst_id, 'Copied Widget'); - $this->assert_is_valid_id($output); + $this->assert_is_valid_id($output->id); - $insts = Api_V1::widget_instances_get($output); + $insts = Api_V1::widget_instances_get($output->id); $this->assert_is_widget_instance($insts[0], true); $this->assertEquals('Copied Widget', $insts[0]->name); $this->assertEquals(true, $insts[0]->is_draft); @@ -577,9 +577,9 @@ public function test_widget_instance_copy() $output = Api_V1::widget_instance_copy($inst_id, 'Copied Widget'); - $this->assert_is_valid_id($output); + $this->assert_is_valid_id($output->id); - $insts = Api_V1::widget_instances_get($output); + $insts = Api_V1::widget_instances_get($output->id); $this->assert_is_widget_instance($insts[0], true); $this->assertEquals('Copied Widget', $insts[0]->name); $this->assertEquals(true, $insts[0]->is_draft); diff --git a/src/components/hooks/useCopyWidget.jsx b/src/components/hooks/useCopyWidget.jsx index 6734b2140..61811706a 100644 --- a/src/components/hooks/useCopyWidget.jsx +++ b/src/components/hooks/useCopyWidget.jsx @@ -14,9 +14,10 @@ export default function useCopyWidget() { { onMutate: async inst => { await queryClient.cancelQueries('widgets', { exact: true, active: true, }) - // 'getQueryData()' is a sync method 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: { @@ -28,19 +29,37 @@ export default function useCopyWidget() { is_fake: true } - let updateData = previousValue - if (updateData) updateData.pagination?.unshift(newInst) + // 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 + }) + })) - // 'setQueryData()' is a sync method - queryClient.setQueryData('widgets', updateData) // can confirm 'widgets' is updating return { previousValue } }, onSuccess: (data, variables) => { - queryClient.invalidateQueries('widgets') + // 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 + }) + })) variables.successFunc(data) }, onError: (err, newWidget, context) => { - queryClient.setQueryData('widgets', context.previousValue) + console.error(err) + queryClient.setQueryData('widgets', (previous) => { + return context.previousValue + }) } } ) diff --git a/src/components/hooks/useInstanceList.jsx b/src/components/hooks/useInstanceList.jsx new file mode 100644 index 000000000..1dd173cf4 --- /dev/null +++ b/src/components/hooks/useInstanceList.jsx @@ -0,0 +1,61 @@ +import { useState, useEffect, useMemo } from 'react' +import { useInfiniteQuery } from 'react-query' +import { apiGetWidgetInstances } from '../../util/api' + +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) => { + 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))].sort(_compareWidgets) + } else return [] + } + + const getWidgetInstances = ({ pageParam = 0}) => { + return apiGetWidgetInstances(pageParam) + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + } = useInfiniteQuery({ + queryKey: ['widgets'], + queryFn: getWidgetInstances, + 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, + ...(errorState == true ? {error: true} : {}) // the error value is only provided if errorState is true + } +} \ No newline at end of file diff --git a/src/components/my-widgets-page.jsx b/src/components/my-widgets-page.jsx index ab6e67b3c..829d7d9a4 100644 --- a/src/components/my-widgets-page.jsx +++ b/src/components/my-widgets-page.jsx @@ -1,11 +1,12 @@ import React, { useState, useEffect, useMemo } from 'react' import { useQuery } from 'react-query' -import { apiGetWidgetInstances, apiGetUser, readFromStorage, apiGetUserPermsForInstance } from '../util/api' +import { apiGetUser, readFromStorage, apiGetUserPermsForInstance } from '../util/api' import rawPermsToObj from '../util/raw-perms-to-object' import Header from './header' import MyWidgetsSideBar from './my-widgets-side-bar' import MyWidgetSelectedInstance from './my-widgets-selected-instance' import LoadingIcon from './loading-icon' +import useInstanceList from './hooks/useInstanceList' import useCopyWidget from './hooks/useCopyWidget' import useDeleteWidget from './hooks/useDeleteWidget' import useKonamiCode from './hooks/useKonamiCode' @@ -23,9 +24,6 @@ const randomBeard = () => { return beard_vals[getRandomInt(0, 3)] } -// Helper function to sort widgets -const _compareWidgets = (a, b) => { return (b.created_at - a.created_at) } - const localBeard = window.localStorage.beardMode const MyWidgetsPage = () => { @@ -39,11 +37,7 @@ const MyWidgetsPage = () => { currentBeard: '' }) - const [widgetList, setWidgetList] = useState({ - instances: [], - page: 1, - totalPages: 0 - }) + const instanceList = useInstanceList() const [invalidLogin, setInvalidLogin] = useState(false) const [showCollab, setShowCollab] = useState(false) @@ -51,35 +45,6 @@ const MyWidgetsPage = () => { const validCode = useKonamiCode() const copyWidget = useCopyWidget() const deleteWidget = useDeleteWidget() - const { - data, - isFetching, - } = useQuery( - ['widgets', widgetList.page], - () => apiGetWidgetInstances(widgetList.page), - { - keepPreviousData: true, - refetchOnWindowFocus: false, - onSuccess: (data) => { - if (!data || data.type == 'error') - { - console.error(`Widget instances failed to load with error: ${data.msg}`); - if (data.title =="Invalid Login") - { - setInvalidLogin(true) - } - } - // Removes duplicates - let widgetSet = new Set([...(data.pagination ? data.pagination : []), ...widgetList.instances]) - - setWidgetList({ - ...widgetList, - totalPages: data.total_num_pages || widgetList.totalPages, - instances: [...widgetSet].sort(_compareWidgets) - }) - }, - } - ) const { data: user } = useQuery({ queryKey: 'user', @@ -105,10 +70,7 @@ const MyWidgetsPage = () => { // hook associated with the invalidLogin error useEffect(() => { - if (invalidLogin) - { - window.location.reload(); - } + if (invalidLogin) window.location.reload(); }, [invalidLogin]) // hook to attach the hashchange event listener to the window @@ -158,13 +120,9 @@ const MyWidgetsPage = () => { }, [state.selectedInst, JSON.stringify(permUsers)]) // hook associated with updates to the widget list OR an update to the widget hash - // facilitates pagination and verifies if the selected widget is loaded when the widget list updates // if there is a widget hash present AND the selected instance does not match the hash, perform an update to the selected widget state info useEffect(() => { - // increment pagination if the list isn't fully loaded - if (widgetList.page < widgetList.totalPages) { - setWidgetList({...widgetList, page: widgetList.page + 1}) - } + if (instanceList.error) setInvalidLogin(true) // if a widget hash exists in the URL OR a widget is already selected in state if ((state.widgetHash && state.widgetHash.length > 0) || state.selectedInst) { @@ -187,7 +145,7 @@ const MyWidgetsPage = () => { if (selectWidget) { // locate the desired widget instance from the widgetList let widgetFound = null - widgetList.instances.forEach((widget, index) => { + instanceList.instances.forEach((widget, index) => { if (widget.id == desiredId) { widgetFound = widget if (selectWidget) onSelect(widget, index) @@ -200,7 +158,8 @@ const MyWidgetsPage = () => { noAccess: widgetFound == null, }) } - } else if (widgetList.page == widgetList.totalPages) { + } + else if (!instanceList.isFetching) { // widgetList is fully loaded and the selected instance is not found // let the user know it's missing or unavailable setState({ @@ -209,20 +168,8 @@ const MyWidgetsPage = () => { noAccess: true, }) } - } else if (state.pendingWidgetHash && widgetList.page == widgetList.totalPages) { - // special case to handle widget copy behavior - // because state.widgetHash and widgetList.instances are both updated concurrently, race conditions could occur - // to resolve this, set a special flag that's addressed AFTER the instance list is re-fetched - // widgetHash is updated only once widgetList.instances is fully populated - // unfortunately this is less responsive than the normal loading of instances, but oh well - let hash = state.pendingWidgetHash - let {pendingWidgetHash, ...stateCopy} = state - setState({ - ...stateCopy, - widgetHash: hash - }) } - }, [widgetList.instances, state.widgetHash, showCollab]) + }, [instanceList.instances, state.widgetHash, showCollab]) // hook to watch otherUserPerms (which despite the name also includes the current user perms) // if the current user is no longer in the perms list, purge the selected instance & force a re-fetch of the list @@ -233,11 +180,6 @@ const MyWidgetsPage = () => { selectedInst: null, widgetHash: null }) - setWidgetList({ - ...widgetList, - instances: [], - page: 1 - }) } },[state.otherUserPerms]) @@ -253,7 +195,7 @@ const MyWidgetsPage = () => { // boolean to verify if the current instance list in state contains the specified instance const selectedInstanceHasLoaded = (inst) => { if (!inst) return false - return widgetList.instances.some(instance => instance.id == inst) + return instanceList.instances.some(instance => instance.id == inst) } // updates necessary state information for a newly selected widget @@ -289,20 +231,11 @@ const MyWidgetsPage = () => { }, { // Still waiting on the widget list to refresh, return to a 'loading' state and indicate a post-fetch change is coming. - onSettled: newInstId => { - // race conditions require we reset selectedInst and widgetHash to null prior to updating those values - // instead, pendingWidgetHash will be used to update widgetHash once the instance list has fully reloaded + onSettled: newInst => { setState({ ...state, selectedInst: null, - widgetHash: null, - pendingWidgetHash: newInstId - }) - - setWidgetList({ - ...widgetList, - instances: [], - page: 1 + widgetHash: newInst.id }) } } @@ -334,12 +267,6 @@ const MyWidgetsPage = () => { selectedInst: null, widgetHash: null }) - - setWidgetList({ - ...widgetList, - instances: [], - page: 1 - }) } } ) @@ -358,16 +285,16 @@ const MyWidgetsPage = () => { const beards = useMemo( () => { const result = [] - widgetList.instances?.forEach(() => { + instanceList.instances?.forEach(() => { result.push(randomBeard()) }) return result }, - [data] + [instanceList.instances] ) let widgetCatalogCalloutRender = null - if (!isFetching && (!widgetList.instances || widgetList.instances?.pagination?.length === 0)) { + if (!instanceList.isFetching && instanceList.instances?.length === 0) { widgetCatalogCalloutRender = (
Click here to start making a new widget! @@ -387,14 +314,14 @@ const MyWidgetsPage = () => { const widgetSpecified = (state.widgetHash || state.selectedInst) // A widget is selected, we're in the process of fetching it but it hasn't returned from the API yet - if (isFetching && widgetSpecified && !selectedInstanceHasLoaded(widgetSpecified)) { + if (instanceList.isFetching && widgetSpecified && !selectedInstanceHasLoaded(widgetSpecified)) { return

Loading Your Widget

} // No widget specified, fetch in progress - if (isFetching && !widgetSpecified) { + if (instanceList.isFetching && !widgetSpecified) { return

Loading

@@ -410,7 +337,7 @@ const MyWidgetsPage = () => { } // Not loading anything and no widgets returned from the API - if (widgetList.instances?.length < 1) { + if (!instanceList.isFetching && instanceList.instances?.length < 1) { return

You have no widgets!

Make a new widget in the widget catalog.

@@ -465,8 +392,8 @@ const MyWidgetsPage = () => {
{ * storage * @returns An array of objects. */ -export const apiGetWidgetInstances = (page_number = 1) => { +export const apiGetWidgetInstances = (page_number = 0) => { return fetch(`/api/json/widget_paginate_instances_get/${page_number}`, fetchOptions({ body: `data=${formatFetchBody([page_number])}` })) .then(resp => { if (resp.status === 204 || resp.status === 502) return [] From 329b27b6ccf78e4ada9b4aaa16d7af0bbd1e754c Mon Sep 17 00:00:00 2001 From: Corey Peterson Date: Tue, 13 Jun 2023 09:50:17 -0400 Subject: [PATCH 2/4] updates created_at for duplicate instances so they populate the top of the instance list when query cache updates --- fuel/app/classes/materia/widget/instance.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fuel/app/classes/materia/widget/instance.php b/fuel/app/classes/materia/widget/instance.php index 742860960..13853f08d 100644 --- a/fuel/app/classes/materia/widget/instance.php +++ b/fuel/app/classes/materia/widget/instance.php @@ -474,6 +474,8 @@ public function duplicate(int $owner_id, string $new_name = null, bool $copy_exi $duplicate->play_url = $duplicate->is_draft === false ? \Config::get('materia.urls.play').$base_url : ''; $duplicate->embed_url = $duplicate->is_draft === false ? \Config::get('materia.urls.embed').$base_url : ''; + $duplicate->created_at = time(); // manually update created_at, the actual value saved to the db is created in db_store + // if original widget is student made - verify if new owner is a student or not // if they have a basic_author role or above, turn off the is_student_made flag if ($duplicate->is_student_made) From 77872cbbe5b195e56c2309215252376529552d7d Mon Sep 17 00:00:00 2001 From: Corey Peterson Date: Tue, 13 Jun 2023 10:34:01 -0400 Subject: [PATCH 3/4] Updates useDeleteWidget hook with pagination improvements --- src/components/hooks/useDeleteWidget.jsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/hooks/useDeleteWidget.jsx b/src/components/hooks/useDeleteWidget.jsx index 246a1422c..036b564cc 100644 --- a/src/components/hooks/useDeleteWidget.jsx +++ b/src/components/hooks/useDeleteWidget.jsx @@ -10,24 +10,30 @@ export default function useDeleteWidget() { // Handles the optimistic update for deleting a widget onMutate: async inst => { await queryClient.cancelQueries('widgets') - const previousValue = queryClient.getQueryData('widgets') - const delID = inst.instId - queryClient.setQueryData('widgets', old => { - if (!old || !old.pagination) return old - return {...old, pagination: old.pagination.filter(widget => widget.id !== delID)} + 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) + })) + } }) // Stores the old value for use if there is an error return { previousValue } }, onSuccess: (data, variables) => { - queryClient.invalidateQueries('widgets') variables.successFunc(data) }, onError: (err, newWidget, context) => { - queryClient.setQueryData('widgets', context.previousValue) + console.error(err) + queryClient.setQueryData('widgets', (previous) => { + return context.previousValue + }) } } ) From c679d79c3f0f272eee22e356603558b059325618 Mon Sep 17 00:00:00 2001 From: Corey Peterson Date: Tue, 13 Jun 2023 13:53:23 -0400 Subject: [PATCH 4/4] Updates lti-select component to use new instanceList hook --- src/components/lti/select-item.jsx | 49 +++++++++--------------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/src/components/lti/select-item.jsx b/src/components/lti/select-item.jsx index 1f64a4481..68abde3ab 100644 --- a/src/components/lti/select-item.jsx +++ b/src/components/lti/select-item.jsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react' -import { useQuery } from 'react-query'; -import { apiGetWidgetInstances } from '../../util/api' +import useInstanceList from '../hooks/useInstanceList' import { iconUrl } from '../../util/icon-url' import LoadingIcon from '../loading-icon'; @@ -14,27 +13,7 @@ const SelectItem = () => { const fillRef = useRef(null) const [progressComplete, setProgressComplete] = useState(false) - const [state, setState] = useState({ - page: 1, - instances: [], - }) - - const { data, isFetching: isFetching, refetch: refetchInstances} = useQuery({ - queryKey: 'instances', - queryFn: () => apiGetWidgetInstances(state.page), - staleTime: Infinity, - onSuccess: (data) => { - if (data) { - data.pagination.map((instance, index) => { - instance.img = iconUrl(BASE_URL + 'widget/', instance.widget.dir, 60) - instance.preview_url = BASE_URL + 'preview/' + instance.id - instance.edit_url = BASE_URL + 'my-widgets/#' + instance.id - }) - - setState({...state, instances: data.pagination}) - } - } - }) + const instanceList = useInstanceList() useEffect(() => { if (window.SYSTEM) { @@ -47,15 +26,15 @@ const SelectItem = () => { if(searchText == '') return result const re = RegExp(searchText, 'i') - if (state.instances && state.instances.length > 0) - state.instances.forEach(i => { + if (instanceList.instances && instanceList.instances.length > 0) + instanceList.instances.forEach(i => { if(!re.test(`${i.name} ${i.widget.name} ${i.id}`)){ result.add(i.id) } }) return result - }, [searchText, state.instances]) + }, [searchText, instanceList.instances]) const handleChange = (e) => { setSearchText(e.target.value) @@ -137,11 +116,11 @@ const SelectItem = () => { } }, [selectedInstance, progressComplete]) - let instanceList = null - if (state.instances && state.instances.length > 0) { - if (hiddenSet.size >= state.instances.length) instanceList =

No widgets match your search.

+ let instanceListRender = null + if (instanceList.instances && instanceList.instances.length > 0) { + if (hiddenSet.size >= instanceList.instances.length) instanceListRender =

No widgets match your search.

else { - instanceList = state.instances.map((instance, index) => { + instanceListRender = instanceList.instances.map((instance, index) => { var classList = [] if (instance.is_draft) classList.push('draft') if (instance.selected) classList.push('selected') @@ -150,7 +129,7 @@ const SelectItem = () => { return
  • - +

    {instance.name}

    {instance.widget.name}

    {instance.guest_access ?

    Guest instances cannot be embedded in courses.

    : <>} @@ -162,7 +141,7 @@ const SelectItem = () => { Preview { (instance.guest_access || instance.is_draft) ? - Edit at Materia + Edit at Materia : embedInstance(instance)}>Use this widget } @@ -174,7 +153,7 @@ const SelectItem = () => { let noInstanceRender = null let createNewInstanceLink = null - if (state.instances && state.instances.length < 1) { + if (instanceList.instances && instanceList.instances.length < 1) { noInstanceRender =

    You don't have any widgets yet. Click this button to create a widget, then return to this tab/window and select your new widget.

    @@ -186,7 +165,7 @@ const SelectItem = () => { } let sectionRender = null - if (isFetching) { + if (instanceList.isFetching) { sectionRender =
    @@ -214,7 +193,7 @@ const SelectItem = () => {
      - {instanceList} + {instanceListRender}
    {createNewInstanceLink}