add TopNavigationTools

closes ADV-104
flag=top_navigation_placement

Test plan:
- NOTE: You must restart the web container after running Setting.set

- PreReqs:
  - A tool with the `top_navigation` placement
- On a Canvas page with the Top Navigation, check the following
 - Without the tool in the allow list
  - ENV does not have key `top_navigation_tools`
  - No tool buttons show up in the navigation bar
 - With the tool in the allow list by dev key
Setting.set("top_navigation_allowed_dev_keys", "global-dev-key-id")
  - ENV.top_navigation_tools is populated with the tool config
  - The tool menu button shows up in the top navigation bar
  - The tool is listed in the menu and can be launched in drawer
  - Adjust the page to mobile width
   - Ensure the tool menu shows up in mobile header and can be launched
    - The tool should open in a tray that overlays the content
  - Repeat with the tool only in the domain list
Setting.set("top_navigation_allowed_dev_keys", "")
Setting.set("top_navigation_allowed_launch_domains", "tool-launch-domain")
 - With the tool in either allow list, add it to the pinned keys list
Setting.set("top_navigation_pinned_dev_keys", "global-dev-key-id")
  - Ensure the tool has a dedicated button in top nav and the tool menu
    is not rendered.
  - Repeate with the tool only in the allowd domains list
Setting.set("top_navigation_pinned_dev_keys", "")
Setting.set("top_navigation_pinned_launch_domains", "tool-launch-domain")
- With the tool in the pinned list but not the allowed list, ensure it
  is not rendered in the top navigation menu and is not in ENV.

Change-Id: I267b78316e54dab7f811e3a7d8eaa752668ac5db
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/347352
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Xander Moffatt <xmoffatt@instructure.com>
QA-Review: Xander Moffatt <xmoffatt@instructure.com>
Product-Review: Dustin Cowles <dustin.cowles@instructure.com>
This commit is contained in:
Dustin Cowles 2024-05-14 14:58:59 -07:00
parent a3cc55f4f6
commit c3e952a1a4
21 changed files with 832 additions and 99 deletions

View File

@ -19,6 +19,7 @@
css_bundle(:instructure_eportfolio) if @eportfolio_view === true
css_bundle(:new_user_tutorials) if tutorials_enabled?
js_bundle(:navigation_header) unless @headers == false
js_bundle(:top_navigation_tools) if @domain_root_account&.feature_enabled?(:top_navigation_placement)
load_blueprint_courses_ui
@has_content_notices = load_content_notices
@ -95,7 +96,7 @@
<%# Flash messages must be outside of #application or they won't work in screenreaders with modals open. %>
<%= render :partial => 'shared/static_notices' %>
<%= render :partial => 'shared/flash_notices' %>
<%if !!@domain_root_account&.feature_enabled?(:external_tool_drawer) %>
<%if @domain_root_account&.feature_enabled?(:top_navigation_placement) %>
<div id="drawer-layout-mount-point"></div>
<% end %>
<div id="application" class="ic-app">
@ -135,6 +136,9 @@
<% end %>
<div class="right-of-crumbs">
<%if @domain_root_account&.feature_enabled?(:top_navigation_placement) %>
<div id="top-nav-tools-mount-point"></div>
<% end %>
<% if tutorials_enabled? %>
<div class="TutorialToggleHolder"></div>
<% end %>

View File

@ -22,6 +22,10 @@
<% js_bundle :nav_tourpoints %>
<% end %>
<% if !!@domain_root_account&.feature_enabled?(:top_navigation_placement) %>
<% js_bundle :top_navigation_tools %>
<% end %>
<%
if k5_user?
dashboard_title = t('Home')
@ -63,6 +67,9 @@
<% else %>
<div class="mobile-header-space"></div>
<% end %>
<% if !!@domain_root_account&.feature_enabled?(:top_navigation_placement) %>
<div id="mobile-top-nav-tools-mount-point"></div>
<% end %>
<% if show_blueprint_button? %>
<button class="mobile-header-blueprint-button Button Button--icon-action-rev Button--large" aria-label="<%= t "Open Blueprint Sidebar" %>">
<i class="icon-blueprint"></i>

View File

@ -45,7 +45,7 @@ describe "add_people" do
# open the add people modal dialog
f("a#addUsers").click
expect(f(".addpeople")).to be_displayed
expect(f("#application")).to have_attribute("aria-hidden", "true")
expect(f("#drawer-layout-mount-point")).to have_attribute("aria-hidden", "true")
# can't click the 'login id' radio button directly, since it's covered
# with inst-ui prettiness and selenium won't allow it.

View File

@ -165,7 +165,7 @@ describe "Tutorials" do
it "the 'Cancel' button closes the End Course Set-up Tutorial modal", priority: "1" do
get "/courses/#{@course.id}"
fj("button:contains('Don\\'t Show Again')").click
fj("span button:contains('Cancel')").click
fj("span[role='dialog'] button:contains('Cancel')").click
expect(f(".NewUserTutorialTray")).to be_displayed
expect(driver).not_to contain_css("End Course Set-up Tutorial")
end

View File

@ -278,7 +278,7 @@ describe "discussions" do
f("[data-testid='grading-schemes-selector-option-#{grading_standard.id}']").click
f(".form-actions button[type=submit]").click
fj("span:contains('Continue')").click
fj(".ui-button-text:contains('Continue')").click
a = DiscussionTopic.last.assignment
expect(a.grading_standard_id).to eq grading_standard.id
end

View File

@ -150,7 +150,9 @@ describe "discussions" do
f('input[type=checkbox][name="assignment[set_assignment]"]').click
f("#has_group_category").click
f(%(span[data-testid="group-set-close"])).click
f("#edit_discussion_form_buttons .btn-primary[type=submit]").click
submit_button = f("#edit_discussion_form_buttons .btn-primary[type=submit]")
scroll_into_view(submit_button)
submit_button.click
wait_for_ajaximations
error_box = f("div[role='alert'] .error_text")
expect(error_box.text).to eq "Please create a group set"

View File

@ -26,6 +26,9 @@ describe "Gradebook History Page" do
include CustomScreenActions
before(:once) do
# This does not currently work correctly with top_navigation_placement enabled
# ADV-112 is open to address this issue
Account.default.disable_feature!(:top_navigation_placement)
gb_history_setup(50)
end

View File

@ -173,8 +173,8 @@ describe "SpeedGrader - rubrics" do
wait_for_ajaximations
scroll_into_view(".toggle_full_rubric")
f("button.toggle_full_rubric").click
fj("span:contains('Rockin\\''):visible").click
fj("span:contains('You Learned'):visible").click
hover_and_click("span:contains('Rockin\\''):visible")
hover_and_click("span:contains('You Learned'):visible")
scroll_into_view(".save_rubric_button")
f("#rubric_holder button.save_rubric_button").click
wait_for_ajaximations

View File

@ -150,7 +150,7 @@ describe "master courses sidebar" do
blueprint_open_sidebar_button.click
f("button#mcSyncHistoryBtn").click
expect(f('span[aria-label="Sync History"]')).to be_displayed
expect(f("#application")).to have_attribute("aria-hidden", "true")
expect(f("#drawer-layout-mount-point")).to have_attribute("aria-hidden", "true")
end
it "shows Unsynced Changes modal when button is clicked" do
@ -160,7 +160,7 @@ describe "master courses sidebar" do
f("button#mcUnsyncedChangesBtn").click
wait_for_ajaximations
expect(f('span[aria-label="Unsynced Changes"]')).to be_displayed
expect(f("#application")).to have_attribute("aria-hidden", "true")
expect(f("#drawer-layout-mount-point")).to have_attribute("aria-hidden", "true")
end
it "does not show the tutorial sidebar button" do

View File

@ -164,7 +164,7 @@ describe "quiz restrictions as a teacher" do
f("#quiz_ip_filter").send_keys("7")
wait_for_new_page_load { f("button.save_quiz_button.btn.btn-primary").click }
expect(ffj(".error_text")[1]).to include_text("IP filter is not valid")
expect(ff(".error_text")[-1]).to include_text("IP filter is not valid")
end
it "has a working link to help with ip address filtering", priority: "1" do

View File

@ -23,7 +23,12 @@ module CustomScreenActions
end
def scroll_page_to_bottom
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
# If the drawer layout is in use, we need to scroll the content element instead
if element_has_children?("#drawer-layout-mount-point")
scroll_element("#drawer-layout-content", "max")
else
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
end
end
def resize_screen_to_standard

View File

@ -208,6 +208,7 @@ const featureBundles: {
terms_of_use: () => import('./features/terms_of_use/index'),
theme_editor: () => import('./features/theme_editor/index'),
theme_preview: () => import('./features/theme_preview/index'),
top_navigation_tools: () => import('./features/top_navigation_tools/index'),
user_grades: () => import('./features/user_grades/index'),
user_lists: () => import('./features/user_lists/index'),
user_logins: () => import('./features/user_logins/index'),

View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {useScope as useI18nScope} from '@canvas/i18n'
import React from 'react'
import ReactDOM from 'react-dom'
import ready from '@instructure/ready'
import ContentTypeExternalToolDrawer from '@canvas/trays/react/ContentTypeExternalToolDrawer'
import {TopNavigationTools, MobileTopNavigationTools} from './react/TopNavigationTools'
import type {Tool} from '@canvas/global/env/EnvCommon'
const I18n = useI18nScope('common')
ready(() => {
const drawerLayoutMountPoint = document.getElementById('drawer-layout-mount-point')
const topNavToolsMountPoint = document.getElementById('top-nav-tools-mount-point')
const mobileTopNavToolsMountPoint = document.getElementById('mobile-top-nav-tools-mount-point')
const canvasApplicationBody = document.getElementById('application')
let selectedTool: Tool | null = null
function handleDismissToolDrawer(): void {
selectedTool = null
renderExternalToolDrawer()
}
function handleToolLaunch(tool: Tool): void {
selectedTool = tool
selectedTool.placement = 'top_navigation'
renderExternalToolDrawer()
}
function handleResize(): void {
renderExternalToolDrawer()
}
function renderExternalToolDrawer(): void {
ReactDOM.render(
<ContentTypeExternalToolDrawer
tool={selectedTool}
pageContent={canvasApplicationBody}
pageContentTitle={I18n.t('Canvas LMS')}
pageContentMinWidth="40rem"
pageContentHeight={window.innerHeight}
trayPlacement="end"
onDismiss={handleDismissToolDrawer}
onResize={handleResize}
open={!!selectedTool}
/>,
drawerLayoutMountPoint
)
}
function renderTopNavigationTools(): void {
ReactDOM.render(
<TopNavigationTools tools={ENV.top_navigation_tools} handleToolLaunch={handleToolLaunch} />,
topNavToolsMountPoint
)
if (mobileTopNavToolsMountPoint) {
ReactDOM.render(
<MobileTopNavigationTools
tools={ENV.top_navigation_tools}
handleToolLaunch={handleToolLaunch}
/>,
mobileTopNavToolsMountPoint
)
}
}
if (drawerLayoutMountPoint && topNavToolsMountPoint && canvasApplicationBody) {
renderExternalToolDrawer()
renderTopNavigationTools()
}
})

View File

@ -0,0 +1,6 @@
{
"name": "@canvas-features/top_navigation_tools",
"private": true,
"version": "1.0.0",
"owner": "FOO"
}

View File

@ -0,0 +1,128 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {Button, IconButton} from '@instructure/ui-buttons'
import {Menu} from '@instructure/ui-menu'
import {IconLtiLine} from '@instructure/ui-icons'
import {Img} from '@instructure/ui-img'
import {TruncateText} from '@instructure/ui-truncate-text'
import {Flex} from '@instructure/ui-flex'
import {useScope as useI18nScope} from '@canvas/i18n'
import type {Tool} from '@canvas/global/env/EnvCommon'
const I18n = useI18nScope('top_navigation_tools')
type TopNavigationToolsProps = {
tools: Tool[]
handleToolLaunch: (tool: Tool) => void // eslint-disable-line react/no-unused-prop-types
}
function getToolIcon(tool: Tool) {
return (
(tool.icon_url && (
<Img src={tool.icon_url} height="1rem" alt={tool.title || 'Tool Icon'} />
)) || <IconLtiLine alt={tool.title || 'Tool Icon'} />
)
}
function handleToolClick(val: String, tools: Tool[], handleToolLaunch: (tool: Tool) => void) {
const targeted_tool = tools.find((tool: Tool) => tool.id === val)
if (targeted_tool) {
handleToolLaunch(targeted_tool)
}
}
export function TopNavigationTools(props: TopNavigationToolsProps) {
const pinned_tools = props.tools.filter(tool => tool.pinned)
const menu_tools = props.tools.filter(tool => !tool.pinned)
return (
<Flex as="div" direct="row" marginEnd="small" marginStart="small" gap="small">
{pinned_tools.map((tool: Tool) => {
return (
<Flex.Item key={tool.id}>
<Button
renderIcon={getToolIcon(tool)}
onClick={e =>
handleToolClick(e.target.dataset.toolId, pinned_tools, props.handleToolLaunch)
}
data-tool-id={tool.id}
>
<TruncateText>{tool.title}</TruncateText>
</Button>
</Flex.Item>
)
})}
{menu_tools.length > 0 && (
<Flex.Item>
<Menu placement="bottom end" trigger={<Button renderIcon={IconLtiLine} />} key="menu">
{menu_tools.map((tool: Tool) => {
return (
<Menu.Item
onSelect={(e, val) => handleToolClick(val, menu_tools, props.handleToolLaunch)}
key={tool.id}
value={tool.id}
label={I18n.t('Launch %{tool}', {tool: tool.title})}
>
<Flex direction="row" gap="small">
{getToolIcon(tool)}
<TruncateText>{tool.title}</TruncateText>
</Flex>
</Menu.Item>
)
})}
</Menu>
</Flex.Item>
)}
</Flex>
)
}
export function MobileTopNavigationTools(props: TopNavigationToolsProps) {
return (
<Menu
placement="bottom end"
trigger={
<IconButton
renderIcon={IconLtiLine}
screenReaderLabel={I18n.t('LTI Tool Menu')}
withBorder={false}
withBackground={false}
/>
}
key="menu"
>
{props.tools.map((tool: Tool) => {
return (
<Menu.Item
onSelect={(e, val) => handleToolClick(val, props)}
key={tool.id}
value={tool.id}
label={I18n.t('Launch %{tool}', {tool: tool.title})}
>
<Flex direction="row" gap="small">
{getToolIcon(tool)}
<TruncateText>{tool.title}</TruncateText>
</Flex>
</Menu.Item>
)
})}
</Menu>
)
}

View File

@ -0,0 +1,116 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {shallow} from 'enzyme'
import {TopNavigationTools, MobileTopNavigationTools} from '../TopNavigationTools'
describe('TopNavigationTools', () => {
it('renders', () => {
const tools = [
{
id: '1',
title: 'Tool 1',
icon_url: 'https://instructure.com',
pinned: true,
},
{
id: '2',
title: 'Tool 2',
pinned: false,
},
]
const handleToolLaunch = jest.fn()
const wrapper = shallow(
<TopNavigationTools tools={tools} handleToolLaunch={handleToolLaunch} />
)
expect(wrapper).toMatchSnapshot()
})
it('renders with no tools', () => {
const tools = []
const handleToolLaunch = jest.fn()
const wrapper = shallow(
<TopNavigationTools tools={tools} handleToolLaunch={handleToolLaunch} />
)
expect(wrapper).toMatchSnapshot()
})
it('renders with no pinned tools', () => {
const tools = [
{
id: '1',
title: 'Tool 1',
icon_url: 'https://instructure.com',
pinned: false,
},
{
id: '2',
title: 'Tool 2',
icon_url: 'https://instructure.com',
pinned: false,
},
]
const handleToolLaunch = jest.fn()
const wrapper = shallow(
<TopNavigationTools tools={tools} handleToolLaunch={handleToolLaunch} />
)
expect(wrapper).toMatchSnapshot()
})
})
describe('MobileTopNavigationTools', () => {
it('renders', () => {
const tools = [
{
id: '1',
title: 'Tool 1',
icon_url: 'https://instructure.com',
pinned: true,
},
{
id: '2',
title: 'Tool 2',
icon_url: 'https://instructure.com',
pinned: true,
},
]
const handleToolLaunch = jest.fn()
const wrapper = shallow(
<MobileTopNavigationTools tools={tools} handleToolLaunch={handleToolLaunch} />
)
expect(wrapper).toMatchSnapshot()
})
})
describe('handleToolClick', () => {
it('finds tool', () => {
const tool = {
id: '1',
title: 'Tool 1',
icon_url: 'https://instructure.com',
pinned: true,
}
const handleToolLaunch = jest.fn()
const wrapper = shallow(
<TopNavigationTools tools={[tool]} handleToolLaunch={handleToolLaunch} />
)
wrapper.find('Button').simulate('click', {target: {dataset: {toolId: '1'}}})
expect(handleToolLaunch).toHaveBeenCalledWith(tool)
})
})

View File

@ -0,0 +1,408 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MobileTopNavigationTools renders 1`] = `
<Menu
constrain="window"
defaultShow={false}
disabled={false}
key="menu"
label={null}
mountNode={null}
offsetX={0}
offsetY={0}
placement="bottom end"
shouldFocusTriggerOnClose={true}
shouldHideOnSelect={true}
trigger={
<IconButton
as="button"
color="secondary"
cursor="pointer"
margin="0"
renderIcon={[Function]}
screenReaderLabel="LTI Tool Menu"
shape="rectangle"
size="medium"
type="button"
withBackground={false}
withBorder={false}
/>
}
withArrow={true}
>
<MenuItem
disabled={false}
key="1"
label="Launch Tool 1"
onSelect={[Function]}
type="button"
value="1"
>
<Flex
as="span"
direction="row"
display="flex"
gap="small"
justifyItems="start"
withVisualDebug={false}
wrap="no-wrap"
>
<Img
alt="Tool 1"
display="inline-block"
height="1rem"
src="https://instructure.com"
withBlur={false}
withGrayscale={false}
/>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 1
</TruncateText>
</Flex>
</MenuItem>
<MenuItem
disabled={false}
key="2"
label="Launch Tool 2"
onSelect={[Function]}
type="button"
value="2"
>
<Flex
as="span"
direction="row"
display="flex"
gap="small"
justifyItems="start"
withVisualDebug={false}
wrap="no-wrap"
>
<Img
alt="Tool 2"
display="inline-block"
height="1rem"
src="https://instructure.com"
withBlur={false}
withGrayscale={false}
/>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 2
</TruncateText>
</Flex>
</MenuItem>
</Menu>
`;
exports[`TopNavigationTools renders 1`] = `
<Flex
as="div"
direct="row"
direction="row"
display="flex"
gap="small"
justifyItems="start"
marginEnd="small"
marginStart="small"
withVisualDebug={false}
wrap="no-wrap"
>
<Item
as="span"
key="1"
shouldGrow={false}
shouldShrink={false}
>
<Button
as="button"
color="secondary"
cursor="pointer"
data-tool-id="1"
display="inline-block"
margin="0"
onClick={[Function]}
renderIcon={
<Img
alt="Tool 1"
display="inline-block"
height="1rem"
src="https://instructure.com"
withBlur={false}
withGrayscale={false}
/>
}
size="medium"
textAlign="center"
type="button"
withBackground={true}
>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 1
</TruncateText>
</Button>
</Item>
<Item
as="span"
shouldGrow={false}
shouldShrink={false}
>
<Menu
constrain="window"
defaultShow={false}
disabled={false}
key="menu"
label={null}
mountNode={null}
offsetX={0}
offsetY={0}
placement="bottom end"
shouldFocusTriggerOnClose={true}
shouldHideOnSelect={true}
trigger={
<Button
as="button"
color="secondary"
cursor="pointer"
display="inline-block"
margin="0"
renderIcon={[Function]}
size="medium"
textAlign="center"
type="button"
withBackground={true}
/>
}
withArrow={true}
>
<MenuItem
disabled={false}
key="2"
label="Launch Tool 2"
onSelect={[Function]}
type="button"
value="2"
>
<Flex
as="span"
direction="row"
display="flex"
gap="small"
justifyItems="start"
withVisualDebug={false}
wrap="no-wrap"
>
<IconLtiLine
alt="Tool 2"
/>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 2
</TruncateText>
</Flex>
</MenuItem>
</Menu>
</Item>
</Flex>
`;
exports[`TopNavigationTools renders with no pinned tools 1`] = `
<Flex
as="div"
direct="row"
direction="row"
display="flex"
gap="small"
justifyItems="start"
marginEnd="small"
marginStart="small"
withVisualDebug={false}
wrap="no-wrap"
>
<Item
as="span"
shouldGrow={false}
shouldShrink={false}
>
<Menu
constrain="window"
defaultShow={false}
disabled={false}
key="menu"
label={null}
mountNode={null}
offsetX={0}
offsetY={0}
placement="bottom end"
shouldFocusTriggerOnClose={true}
shouldHideOnSelect={true}
trigger={
<Button
as="button"
color="secondary"
cursor="pointer"
display="inline-block"
margin="0"
renderIcon={[Function]}
size="medium"
textAlign="center"
type="button"
withBackground={true}
/>
}
withArrow={true}
>
<MenuItem
disabled={false}
key="1"
label="Launch Tool 1"
onSelect={[Function]}
type="button"
value="1"
>
<Flex
as="span"
direction="row"
display="flex"
gap="small"
justifyItems="start"
withVisualDebug={false}
wrap="no-wrap"
>
<Img
alt="Tool 1"
display="inline-block"
height="1rem"
src="https://instructure.com"
withBlur={false}
withGrayscale={false}
/>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 1
</TruncateText>
</Flex>
</MenuItem>
<MenuItem
disabled={false}
key="2"
label="Launch Tool 2"
onSelect={[Function]}
type="button"
value="2"
>
<Flex
as="span"
direction="row"
display="flex"
gap="small"
justifyItems="start"
withVisualDebug={false}
wrap="no-wrap"
>
<Img
alt="Tool 2"
display="inline-block"
height="1rem"
src="https://instructure.com"
withBlur={false}
withGrayscale={false}
/>
<TruncateText
debounce={0}
ellipsis="…"
ignore={
[
" ",
".",
",",
]
}
maxLines={1}
position="end"
truncate="character"
>
Tool 2
</TruncateText>
</Flex>
</MenuItem>
</Menu>
</Item>
</Flex>
`;
exports[`TopNavigationTools renders with no tools 1`] = `
<Flex
as="div"
direct="row"
direction="row"
display="flex"
gap="small"
justifyItems="start"
marginEnd="small"
marginStart="small"
withVisualDebug={false}
wrap="no-wrap"
/>
`;

View File

@ -41,6 +41,17 @@ type Role = {
plural_label: string
}
type ToolPlacement = 'top_navigation'
export type Tool = {
id: string
title: string
base_url: string
icon_url: string
pinned?: boolean
placement?: ToolPlacement
}
export type GroupOutcome = {
id: string
title: string
@ -210,6 +221,12 @@ export interface EnvCommon {
classes?: string
}>
breadcrumbs: {name: string; url: string}[]
/**
* Used by ui/features/top_navigation_tools/react/TopNavigationTools.tsx
* and ui/shared/trays/react/ContentTypeExternalToolDrawer.tsx
*/
top_navigation_tools: Tool[]
}
/**

View File

@ -25,6 +25,9 @@ import parseQuestionId from '../util/parse_question_id'
export default class QuestionViewed extends EventTracker {
install(deliver, scrollContainer = window) {
// The quiz might be inside a drawer layout, in which case we need to
// watch the content inside the drawer layout instead of the window.
scrollContainer = document.getElementById('drawer-layout-content') || scrollContainer
let viewed = []
return this.bind(

View File

@ -27,47 +27,19 @@ import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view'
import {handleExternalContentMessages} from '@canvas/external-tools/messages'
import ToolLaunchIframe from '@canvas/external-tools/react/components/ToolLaunchIframe'
type Tool = {
id: string
title: string
base_url: string
icon_url: string
}
type KnownResourceType =
| 'assignment'
| 'assignment_group'
| 'audio'
| 'discussion_topic'
| 'document'
| 'image'
| 'module'
| 'quiz'
| 'page'
| 'video'
export type SelectableItem = {
course_id: string
type: KnownResourceType
}
import type {Tool} from '@canvas/global/env/EnvCommon'
type Props = {
tool: Tool
tool: Tool | null
pageContent: Element
pageContentTitle: string
pageContentMinWidth: string
pageContentHeight: string
trayPlacement: string
acceptedResourceTypes: KnownResourceType[]
targetResourceType: KnownResourceType
allowItemSelection: boolean
selectableItems: SelectableItem[]
onDismiss: any
onExternalContentReady: any
onResize: any
onExternalContentReady?: any
open: boolean
placement: string
extraQueryParams?: {}
}
export default function ContentTypeExternalToolDrawer({
@ -77,30 +49,17 @@ export default function ContentTypeExternalToolDrawer({
pageContentMinWidth,
pageContentHeight,
trayPlacement,
acceptedResourceTypes,
targetResourceType,
allowItemSelection,
selectableItems,
onDismiss,
onResize,
onExternalContentReady,
open,
placement,
extraQueryParams = {},
}: Props) {
const queryParams = {
com_instructure_course_accept_canvas_resource_types: acceptedResourceTypes,
com_instructure_course_canvas_resource_type: targetResourceType,
com_instructure_course_allow_canvas_resource_selection: allowItemSelection,
com_instructure_course_available_canvas_resources: selectableItems,
display: 'borderless',
placement,
...extraQueryParams,
}
const queryParams = tool ? {display: 'borderless', placement: tool.placement} : {}
const prefix = tool?.base_url.indexOf('?') === -1 ? '?' : '&'
const iframeUrl = `${tool?.base_url}${prefix}${$.param(queryParams)}`
const toolTitle = tool ? tool.title : 'External Tool'
const toolIconUrl = tool ? tool.icon_url : ''
const toolIconAlt = toolTitle ? `${toolTitle} icon` : ''
const toolIconUrl = tool?.icon_url
const toolIconAlt = toolTitle ? `${toolTitle} Icon` : 'Tool Icon'
const iframeRef = useRef()
const pageContentRef = useRef()
@ -115,6 +74,14 @@ export default function ContentTypeExternalToolDrawer({
[pageContent]
)
useEffect(() => {
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
}
}, [onResize])
useEffect(
// returns cleanup function:
() => handleExternalContentMessages({ready: onExternalContentReady}),
@ -124,7 +91,7 @@ export default function ContentTypeExternalToolDrawer({
return (
<View display="block" height={pageContentHeight}>
<DrawerLayout minWidth={pageContentMinWidth}>
<DrawerLayout.Content label={pageContentTitle}>
<DrawerLayout.Content label={pageContentTitle} id="drawer-layout-content">
<div ref={pageContentRef} />
</DrawerLayout.Content>
<DrawerLayout.Tray

View File

@ -21,7 +21,13 @@ import {render, fireEvent} from '@testing-library/react'
import ContentTypeExternalToolDrawer from '../ContentTypeExternalToolDrawer'
describe('ContentTypeExternalToolDrawer', () => {
const tool = {id: '1', base_url: 'https://one.lti.com/', title: 'First LTI'}
const tool = {
id: '1',
base_url: 'https://lti1.example.com/',
title: 'First LTI',
pinned: true,
placement: 'top_navigation',
}
const onDismiss = jest.fn()
const onExternalContentReady = jest.fn()
const extraQueryParams = {key1: 'value1', key2: 'value2'}
@ -39,17 +45,11 @@ describe('ContentTypeExternalToolDrawer', () => {
tool={tool}
pageContent={pageContent}
pageContentTitle={pageContentTitle}
pageContentMinWidth=""
pageContentMinWidth="40rem"
trayPlacement="end"
acceptedResourceTypes={['page', 'module']}
targetResourceType="page"
allowItemSelection={true}
selectableItems={[{id: '1', name: 'module 1'}]}
onDismiss={onDismiss}
onExternalContentReady={onExternalContentReady}
open={true}
placement="wiki_index_menu"
extraQueryParams={extraQueryParams}
{...props}
/>
)
@ -75,7 +75,7 @@ describe('ContentTypeExternalToolDrawer', () => {
let icon_url
beforeAll(() => {
icon_url = 'https://one.lti.com/icon.png'
icon_url = 'https://lti1.example.com/icon.png'
tool.icon_url = icon_url
})
@ -85,7 +85,7 @@ describe('ContentTypeExternalToolDrawer', () => {
it('renders an icon', () => {
const {getByAltText} = renderTray()
expect(getByAltText('First LTI icon')).toHaveAttribute('src', icon_url)
expect(getByAltText('First LTI Icon')).toHaveAttribute('src', icon_url)
})
})
@ -103,33 +103,10 @@ describe('ContentTypeExternalToolDrawer', () => {
})
})
describe('constructs iframe src url', () => {
it('adds ? before parameters if none are already present', () => {
expect(tool.base_url).not.toContain('?')
const {getByTestId} = renderTray()
const src = getByTestId('ltiIframe').src
expect(src).toContain(`${tool.base_url}?`)
})
it('appends parameters if some exist already', () => {
tool.base_url = 'https://one.lti.com/?launch_type=wiki_index_menu'
const {getByTestId} = renderTray()
const src = getByTestId('ltiIframe').src
expect(src).toContain(`${tool.base_url}&`)
})
it('includes expected parameters', () => {
const {getByTestId} = renderTray()
const src = getByTestId('ltiIframe').src
expect(src).toContain('com_instructure_course_accept_canvas_resource_types')
expect(src).toContain('com_instructure_course_canvas_resource_type')
expect(src).toContain('com_instructure_course_allow_canvas_resource_selection')
expect(src).toContain('com_instructure_course_available_canvas_resources')
expect(src).toContain('display')
expect(src).toContain('placement')
// from extraQueryParams
expect(src).toContain('key1=value1')
expect(src).toContain('key2=value2')
})
it('constructs iframe src url', () => {
expect(tool.base_url).not.toContain('?')
const {getByTestId} = renderTray()
const src = getByTestId('ltiIframe').src
expect(src).toContain(`${tool.base_url}?`)
})
})