From 2c5c22c553c340aedc3aaf98a8c0b8d190131eeb Mon Sep 17 00:00:00 2001 From: Matthew Buckett Date: Mon, 26 Jan 2026 10:56:57 +0000 Subject: [PATCH] Switch to typescript This allows us to easily output types for our components and improves IDE/AI use of the library. It also makes the library easier to use in an existing project that uses typescript. --- .npmignore | 2 +- package-lock.json | 14 +++++ package.json | 18 +++++-- .../LtiPageSettings.stories.jsx | 2 +- .../LtiPageSettings/LtiPageSettings.test.jsx | 2 +- ...tiPageSettings.jsx => LtiPageSettings.tsx} | 33 +++++++----- .../applyTheme/LtiApplyTheme.stories.jsx | 2 +- .../applyTheme/LtiApplyTheme.test.jsx | 2 +- .../{LtiApplyTheme.jsx => LtiApplyTheme.tsx} | 24 ++++++--- .../errorBillboard/ErrorBillboard.stories.jsx | 2 +- .../errorBillboard/ErrorBillboard.test.jsx | 2 +- ...{ErrorBillboard.jsx => ErrorBillboard.tsx} | 13 +++-- .../heightLimit/LtiHeightLimit.stories.jsx | 2 +- .../heightLimit/LtiHeightLimit.test.jsx | 2 +- ...{LtiHeightLimit.jsx => LtiHeightLimit.tsx} | 54 ++++++++++++++----- .../launchOAuth/LaunchOAuth.stories.jsx | 2 +- .../launchOAuth/LaunchOAuth.test.jsx | 2 +- .../{LaunchOAuth.jsx => LaunchOAuth.tsx} | 28 +++++++--- .../modal/LtiLimitModal.stories.jsx | 2 +- src/components/modal/LtiLimitModal.test.jsx | 2 +- .../{LtiLimitModal.jsx => LtiLimitModal.tsx} | 47 +++++++++------- .../promptOAuth/PromptOAuth.stories.jsx | 2 +- .../promptOAuth/PromptOAuth.test.jsx | 2 +- .../{PromptOAuth.jsx => PromptOAuth.tsx} | 12 ++++- .../LtiTokenRetriever.stories.jsx | 2 +- .../tokenRetriever/LtiTokenRetriever.test.jsx | 2 +- ...kenRetriever.jsx => LtiTokenRetriever.tsx} | 44 ++++++++++----- src/index.js | 10 ---- src/index.ts | 10 ++++ src/types/instructure-ui.d.ts | 9 ++++ tsconfig.build.json | 17 ++++++ tsconfig.json | 18 +++++++ vite.config.js | 2 +- 33 files changed, 283 insertions(+), 104 deletions(-) rename src/components/LtiPageSettings/{LtiPageSettings.jsx => LtiPageSettings.tsx} (82%) rename src/components/applyTheme/{LtiApplyTheme.jsx => LtiApplyTheme.tsx} (78%) rename src/components/errorBillboard/{ErrorBillboard.jsx => ErrorBillboard.tsx} (72%) rename src/components/heightLimit/{LtiHeightLimit.jsx => LtiHeightLimit.tsx} (77%) rename src/components/launchOAuth/{LaunchOAuth.jsx => LaunchOAuth.tsx} (79%) rename src/components/modal/{LtiLimitModal.jsx => LtiLimitModal.tsx} (67%) rename src/components/promptOAuth/{PromptOAuth.jsx => PromptOAuth.tsx} (93%) rename src/components/tokenRetriever/{LtiTokenRetriever.jsx => LtiTokenRetriever.tsx} (80%) delete mode 100644 src/index.js create mode 100644 src/index.ts create mode 100644 src/types/instructure-ui.d.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.npmignore b/.npmignore index a07c313..80ca1db 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,2 @@ -.idea + *.tgz diff --git a/package-lock.json b/package-lock.json index e6630d7..9436fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.4.1", "jsdom": "^25.0.1", "msw": "^2.6.6", @@ -38,6 +40,7 @@ "prop-types": "^15.7.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "typescript": "^5.5.4", "vite": "6.4.1", "vitest": "^3.2.4" }, @@ -4762,6 +4765,17 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", diff --git a/package.json b/package.json index 9ef5d0c..594e31b 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "description": "Common UI components that are commonly used for LTI tools in the Canvas LMS", "module": "dist/ui-lti.js", "main": "dist/ui-lti.cjs", + "types": "dist/index.d.ts", "exports": { - "import": "./dist/ui-lti.js", - "require": "./dist/ui-lti.cjs" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/ui-lti.js", + "require": "./dist/ui-lti.cjs" + } }, "repository": { "url": "https://github.com/oxctl/ui-lti" @@ -20,13 +24,14 @@ "storybook": "sb dev -- -p 6006", "storybook-https": "sb dev -- -p 6006 --https --ssl-cert localhost.pem --ssl-key localhost-key.pem", "build-storybook": "sb build", - "build": "vite build", + "build": "vite build && tsc -p tsconfig.build.json", + "prepack": "npm run build", "start": "vite", "test": "vitest" }, "files": [ - "src/*", - "dist/*" + "dist/**", + "src/**" ], "devDependencies": { "@instructure/canvas-theme": "^10.18.0", @@ -51,6 +56,8 @@ "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.4.1", "jsdom": "^25.0.1", "msw": "^2.6.6", @@ -58,6 +65,7 @@ "prop-types": "^15.7.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "typescript": "^5.5.4", "vite": "6.4.1", "vitest": "^3.2.4" }, diff --git a/src/components/LtiPageSettings/LtiPageSettings.stories.jsx b/src/components/LtiPageSettings/LtiPageSettings.stories.jsx index e00fdc3..6cabd5c 100644 --- a/src/components/LtiPageSettings/LtiPageSettings.stories.jsx +++ b/src/components/LtiPageSettings/LtiPageSettings.stories.jsx @@ -1,5 +1,5 @@ import React from 'react' -import {LtiPageSettings} from './LtiPageSettings.jsx' +import {LtiPageSettings} from './LtiPageSettings' import { ColorIndicator } from '@instructure/ui-color-picker' import { View } from '@instructure/ui-view' import { Avatar } from '@instructure/ui-avatar' diff --git a/src/components/LtiPageSettings/LtiPageSettings.test.jsx b/src/components/LtiPageSettings/LtiPageSettings.test.jsx index cb09511..a873215 100644 --- a/src/components/LtiPageSettings/LtiPageSettings.test.jsx +++ b/src/components/LtiPageSettings/LtiPageSettings.test.jsx @@ -2,7 +2,7 @@ import {useContext} from 'react' import '@testing-library/jest-dom'; import {render, screen, waitFor} from '@testing-library/react' import {describe, expect, it} from 'vitest' -import {LtiPageSettings, PageSettingsContext} from "./LtiPageSettings.jsx"; +import {LtiPageSettings, PageSettingsContext} from "./LtiPageSettings"; describe('LtiPageSettings Test Suite', () => { it('renders children correctly and sets context', async () => { diff --git a/src/components/LtiPageSettings/LtiPageSettings.jsx b/src/components/LtiPageSettings/LtiPageSettings.tsx similarity index 82% rename from src/components/LtiPageSettings/LtiPageSettings.jsx rename to src/components/LtiPageSettings/LtiPageSettings.tsx index e2dda4f..7a105af 100644 --- a/src/components/LtiPageSettings/LtiPageSettings.jsx +++ b/src/components/LtiPageSettings/LtiPageSettings.tsx @@ -1,4 +1,5 @@ import { createContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' import { InstUISettingsProvider } from '@instructure/emotion' import { canvas, canvasHighContrast } from '@instructure/ui-themes' @@ -6,7 +7,9 @@ import { canvas, canvasHighContrast } from '@instructure/ui-themes' * This context provides the page settings that nested components can use. * @type {React.Context<{}>} */ -const PageSettingsContext = createContext({}) +type PageSettings = Record + +const PageSettingsContext = createContext({}) /** * This component retrieves the page settings from Canvas and if a theme is provided loads it and applies it to the children. @@ -14,21 +17,27 @@ const PageSettingsContext = createContext({}) * @param debug if true, debug messages will be logged to the console * @param themeRetries the number of times to retry fetching the theme if it fails */ -function LtiPageSettings({ children, debug = false, themeRetries = 1 }) { +type LtiPageSettingsProps = { + children: ReactNode + debug?: boolean + themeRetries?: number +} + +function LtiPageSettings({ children, debug = false, themeRetries = 1 }: LtiPageSettingsProps) { // A copy of the pages settings sent from Canvas. - const [pageSettings, setPageSettings] = useState({}) + const [pageSettings, setPageSettings] = useState({}) // The theme for Instructure UI components. - const [theme, setTheme] = useState({}) + const [theme, setTheme] = useState>({}) /** * This is a debug function that will log messages to the console if debug is enabled. * @param message Message to log */ - const logDebug = (message) => { + const logDebug = (message: string) => { debug && console.debug(message) } - const fetchTheme = async (themeUrl) => { + const fetchTheme = async (themeUrl?: string) => { if (themeUrl) { for (let attempt = 0; attempt <= themeRetries; attempt++) { try { @@ -50,19 +59,19 @@ function LtiPageSettings({ children, debug = false, themeRetries = 1 }) { useEffect(() => { let receivedPageSettings = false const targetWindow = window.parent || window.opener - const messageHandler = (event) => { - if (event.data.subject === 'lti.getPageSettings.response') { + const messageHandler = (event: MessageEvent) => { + if (event.data?.subject === 'lti.getPageSettings.response') { logDebug('Received page settings response from: ' + event.origin + ' with data: ' + JSON.stringify(event.data, null, 2)) - const pageSettings = event.data.pageSettings + const pageSettings = event.data.pageSettings as PageSettings | undefined if (pageSettings) { setPageSettings(pageSettings) receivedPageSettings = true - const highContrast = pageSettings.use_high_contrast + const highContrast = pageSettings.use_high_contrast as boolean | undefined if (highContrast) { logDebug('High contrast mode enabled, not loading custom theme') setTheme(canvasHighContrast) } else { - const themeUrl = pageSettings.active_brand_config_json_url + const themeUrl = pageSettings.active_brand_config_json_url as string | undefined logDebug('Loading theme from URL: ' + themeUrl) fetchTheme(themeUrl) .then(variables => { @@ -106,4 +115,4 @@ function LtiPageSettings({ children, debug = false, themeRetries = 1 }) { ) } -export { LtiPageSettings, PageSettingsContext } \ No newline at end of file +export { LtiPageSettings, PageSettingsContext } diff --git a/src/components/applyTheme/LtiApplyTheme.stories.jsx b/src/components/applyTheme/LtiApplyTheme.stories.jsx index c277ee8..358a194 100644 --- a/src/components/applyTheme/LtiApplyTheme.stories.jsx +++ b/src/components/applyTheme/LtiApplyTheme.stories.jsx @@ -1,5 +1,5 @@ import React from 'react' -import LtiApplyTheme from './LtiApplyTheme.jsx' +import LtiApplyTheme from './LtiApplyTheme' import { ColorIndicator } from '@instructure/ui-color-picker' import { View } from '@instructure/ui-view' import { Avatar } from '@instructure/ui-avatar' diff --git a/src/components/applyTheme/LtiApplyTheme.test.jsx b/src/components/applyTheme/LtiApplyTheme.test.jsx index e622d3b..56928f0 100644 --- a/src/components/applyTheme/LtiApplyTheme.test.jsx +++ b/src/components/applyTheme/LtiApplyTheme.test.jsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import LtiApplyTheme from "./LtiApplyTheme.jsx"; +import LtiApplyTheme from "./LtiApplyTheme"; import {act, render, screen} from '@testing-library/react' import {describe, expect, it} from 'vitest' diff --git a/src/components/applyTheme/LtiApplyTheme.jsx b/src/components/applyTheme/LtiApplyTheme.tsx similarity index 78% rename from src/components/applyTheme/LtiApplyTheme.jsx rename to src/components/applyTheme/LtiApplyTheme.tsx index b566d36..8dd21a3 100644 --- a/src/components/applyTheme/LtiApplyTheme.jsx +++ b/src/components/applyTheme/LtiApplyTheme.tsx @@ -3,11 +3,22 @@ import PropTypes from 'prop-types' import { InstUISettingsProvider } from '@instructure/emotion' import { canvasHighContrast, canvas } from '@instructure/ui-themes' +type LtiApplyThemeProps = { + url?: string | null + highContrast?: boolean + children: React.ReactNode + maxRetries?: number +} + +type LtiApplyThemeState = { + theme: Record +} + /** * This attempts to load the theme from the supplied URL and then applies the theme to all the children. * If this isn't working, it's very possible you have multiple copies of instructure ui */ -export class LtiApplyTheme extends React.Component { +export class LtiApplyTheme extends React.Component { static propTypes = { /** @@ -26,7 +37,7 @@ export class LtiApplyTheme extends React.Component { } loading = false - state = { + state: LtiApplyThemeState = { theme: {} } @@ -37,9 +48,10 @@ export class LtiApplyTheme extends React.Component { /** * This fetches the custom theme variables for the instance. */ - fetchVariables = async () => { + fetchVariables = async (): Promise> => { if (this.props.url) { - for (let attempt = 0; attempt <= this.props.maxRetries; attempt++) { + const maxRetries = this.props.maxRetries ?? 1 + for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const variables = await fetch(this.props.url) .then(response => { @@ -70,7 +82,7 @@ export class LtiApplyTheme extends React.Component { } } - componentDidUpdate(prevProps, prevState, snapshot) { + componentDidUpdate(prevProps: LtiApplyThemeProps) { if (this.props.url !== prevProps.url || this.props.highContrast !== prevProps.highContrast ) { @@ -87,4 +99,4 @@ export class LtiApplyTheme extends React.Component { } } -export default LtiApplyTheme \ No newline at end of file +export default LtiApplyTheme diff --git a/src/components/errorBillboard/ErrorBillboard.stories.jsx b/src/components/errorBillboard/ErrorBillboard.stories.jsx index a7eb20e..904fb15 100644 --- a/src/components/errorBillboard/ErrorBillboard.stories.jsx +++ b/src/components/errorBillboard/ErrorBillboard.stories.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import ErrorBillboard from './ErrorBillboard.jsx'; +import ErrorBillboard from './ErrorBillboard'; const Template = (args) => diff --git a/src/components/errorBillboard/ErrorBillboard.test.jsx b/src/components/errorBillboard/ErrorBillboard.test.jsx index 8c8c4f3..41197fd 100644 --- a/src/components/errorBillboard/ErrorBillboard.test.jsx +++ b/src/components/errorBillboard/ErrorBillboard.test.jsx @@ -2,7 +2,7 @@ import React from 'react' import '@testing-library/jest-dom'; import {render, screen} from '@testing-library/react' import {describe, expect, it} from 'vitest' -import ErrorBillboard from "./ErrorBillboard.jsx"; +import ErrorBillboard from "./ErrorBillboard"; const mockHeader = "Test Error Heading" let mockMessage = "Test Message" diff --git a/src/components/errorBillboard/ErrorBillboard.jsx b/src/components/errorBillboard/ErrorBillboard.tsx similarity index 72% rename from src/components/errorBillboard/ErrorBillboard.jsx rename to src/components/errorBillboard/ErrorBillboard.tsx index f24e573..e54dfe6 100644 --- a/src/components/errorBillboard/ErrorBillboard.jsx +++ b/src/components/errorBillboard/ErrorBillboard.tsx @@ -2,13 +2,20 @@ import React from 'react' import PropTypes from 'prop-types' import {Billboard} from '@instructure/ui-billboard' import {IconWarningLine} from '@instructure/ui-icons' +import {HeroIconSize} from "@instructure/ui-billboard/types/Billboard/props"; + +type ErrorBillboardProps = { + heading?: string + message?: string | null + children: React.ReactNode +} /** * This either renders the child components or the error if it's present. * This is designed to handle toplevel errors when something has gone seriously wrong and we don't want to display * the rest of the application. */ -export class ErrorBillBoard extends React.Component { +export class ErrorBillBoard extends React.Component { static propTypes = { // The heading of the error. @@ -31,9 +38,9 @@ export class ErrorBillBoard extends React.Component { heading={heading} message={message} size="large" - hero={(size) => } + hero={(size: HeroIconSize) => } />) : children } } -export default ErrorBillBoard \ No newline at end of file +export default ErrorBillBoard diff --git a/src/components/heightLimit/LtiHeightLimit.stories.jsx b/src/components/heightLimit/LtiHeightLimit.stories.jsx index c94a828..69fe19f 100644 --- a/src/components/heightLimit/LtiHeightLimit.stories.jsx +++ b/src/components/heightLimit/LtiHeightLimit.stories.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import LtiHeightLimit from './LtiHeightLimit.jsx'; +import LtiHeightLimit from './LtiHeightLimit'; const Template = () =>
Test Height Limit
diff --git a/src/components/heightLimit/LtiHeightLimit.test.jsx b/src/components/heightLimit/LtiHeightLimit.test.jsx index 846776a..264cc58 100644 --- a/src/components/heightLimit/LtiHeightLimit.test.jsx +++ b/src/components/heightLimit/LtiHeightLimit.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import '@testing-library/jest-dom'; -import LtiHeightLimit from "./LtiHeightLimit.jsx"; +import LtiHeightLimit from "./LtiHeightLimit"; import {render, screen} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' diff --git a/src/components/heightLimit/LtiHeightLimit.jsx b/src/components/heightLimit/LtiHeightLimit.tsx similarity index 77% rename from src/components/heightLimit/LtiHeightLimit.jsx rename to src/components/heightLimit/LtiHeightLimit.tsx index 093fdca..b2ff2cd 100644 --- a/src/components/heightLimit/LtiHeightLimit.jsx +++ b/src/components/heightLimit/LtiHeightLimit.tsx @@ -4,23 +4,47 @@ import React from 'react' import PropTypes from 'prop-types' -const LtiHeightLimitContext = React.createContext({ - get: () => {}, +type LtiHeightLimitContextValue = { + get: () => boolean + set: (limit: boolean) => void +} + +const LtiHeightLimitContext = React.createContext({ + get: () => false, set: () => {} }) + +type LtiHeightLimitProps = { + debug?: boolean + children: React.ReactNode +} + +type LtiHeightLimitState = { + height: number | 'auto' + limit: boolean + debug: boolean +} + +type LtiWindowSizeMessage = { + subject: string + height?: number + offset?: { + top?: number + } +} /** * This is a Component that will attempt to resize the LTI iframe to the size of the content, but also when asked * will attempt to remove all the scroll bars from the iframe so that things like modals can be displayed. * This is useful when needing to display something like a Modal in the middle of the viewport (or close to it). */ -export class LtiHeightLimit extends React.Component { +export class LtiHeightLimit extends React.Component { static propTypes = { debug: PropTypes.bool, children: PropTypes.node.isRequired } - state = { + state: LtiHeightLimitState = { // The height to resize to when we are limit our height. height: 'auto', // Should we limit this component's height to that of the containing iframe? @@ -29,6 +53,9 @@ export class LtiHeightLimit extends React.Component { debug: false } + observer: MutationObserver | null = null + message?: LtiWindowSizeMessage + componentDidMount() { window.addEventListener('resize', this.resizeListener) window.addEventListener('message', this.messageListener) @@ -38,7 +65,7 @@ export class LtiHeightLimit extends React.Component { this.resize() } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: LtiHeightLimitProps, prevState: LtiHeightLimitState) { if (!prevState.limit && this.state.limit) { this.resize() } @@ -47,10 +74,10 @@ export class LtiHeightLimit extends React.Component { componentWillUnmount() { window.removeEventListener('resize', this.resizeListener) window.removeEventListener('message', this.messageListener) - this.observer.disconnect() + this.observer?.disconnect() } - logDebug = (message) => { + logDebug = (message: string) => { if (this.props.debug) { console.debug(message) } @@ -87,10 +114,10 @@ export class LtiHeightLimit extends React.Component { } } - messageListener = e => e.data && this.messageHandler(e) + messageListener = (e: MessageEvent) => e.data && this.messageHandler(e) - messageHandler = (event) => { - let message + messageHandler = (event: MessageEvent) => { + let message: LtiWindowSizeMessage try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data } catch (err) { @@ -100,10 +127,13 @@ export class LtiHeightLimit extends React.Component { if (message.subject === 'lti.fetchWindowSize.response') { this.message = message let height = message.height + if (typeof height !== 'number') { + return + } // When launched from a deep linking placement there doesn't appear to be a offset in the message // returned if (message.offset) { - height -= message.offset.top + height -= message.offset.top ?? 0 } this.logDebug(`Got height message with value of: ${height}`) this.setState({height}) @@ -136,4 +166,4 @@ export class LtiHeightLimit extends React.Component { export default LtiHeightLimit -export { LtiHeightLimitContext } \ No newline at end of file +export { LtiHeightLimitContext } diff --git a/src/components/launchOAuth/LaunchOAuth.stories.jsx b/src/components/launchOAuth/LaunchOAuth.stories.jsx index abe1e8f..806e90e 100644 --- a/src/components/launchOAuth/LaunchOAuth.stories.jsx +++ b/src/components/launchOAuth/LaunchOAuth.stories.jsx @@ -1,5 +1,5 @@ import React, { Fragment } from 'react' -import LaunchOAuth from './LaunchOAuth.jsx' +import LaunchOAuth from './LaunchOAuth' export default { title: 'Components/LaunchOAuth', diff --git a/src/components/launchOAuth/LaunchOAuth.test.jsx b/src/components/launchOAuth/LaunchOAuth.test.jsx index 9b9c19d..543f97d 100644 --- a/src/components/launchOAuth/LaunchOAuth.test.jsx +++ b/src/components/launchOAuth/LaunchOAuth.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import '@testing-library/jest-dom'; -import LaunchOAuth from "./LaunchOAuth.jsx"; +import LaunchOAuth from "./LaunchOAuth"; import {render, screen} from '@testing-library/react' import {describe, expect, it} from 'vitest' diff --git a/src/components/launchOAuth/LaunchOAuth.jsx b/src/components/launchOAuth/LaunchOAuth.tsx similarity index 79% rename from src/components/launchOAuth/LaunchOAuth.jsx rename to src/components/launchOAuth/LaunchOAuth.tsx index 4688ccf..c4ecfc2 100644 --- a/src/components/launchOAuth/LaunchOAuth.jsx +++ b/src/components/launchOAuth/LaunchOAuth.tsx @@ -3,10 +3,24 @@ import PropTypes from 'prop-types' import { Billboard } from '@instructure/ui-billboard' import { IconWarningLine } from '@instructure/ui-icons' +type LaunchOAuthServer = + | string + | { + proxyServer: string + } + +type LaunchOAuthProps = { + accessToken?: string | null + children: React.ReactNode + promptUserLogin: () => void + promptLogin: boolean + server: LaunchOAuthServer +} + /** * This either displays the child components or it displays a message asking the user to login. */ -export class LaunchOAuth extends React.Component { +export class LaunchOAuth extends React.Component { static propTypes = { /** @@ -40,11 +54,13 @@ export class LaunchOAuth extends React.Component { accessToken: null } - constructor(props) { + constructor(props: LaunchOAuthProps) { super(props) - this.formRef = React.createRef() + this.formRef = React.createRef() } + formRef: React.RefObject + componentDidMount() { window.addEventListener("message", (event) => { if (event.data === 'token') { @@ -54,7 +70,7 @@ export class LaunchOAuth extends React.Component { } handleLogin = () => { - this.formRef.current.submit() + this.formRef.current?.submit() } render() { @@ -74,7 +90,7 @@ export class LaunchOAuth extends React.Component { } + hero={(size: unknown) => } size="large" onClick={() => this.handleLogin()} /> @@ -89,4 +105,4 @@ export class LaunchOAuth extends React.Component { } } -export default LaunchOAuth \ No newline at end of file +export default LaunchOAuth diff --git a/src/components/modal/LtiLimitModal.stories.jsx b/src/components/modal/LtiLimitModal.stories.jsx index 82e6c3a..1017081 100644 --- a/src/components/modal/LtiLimitModal.stories.jsx +++ b/src/components/modal/LtiLimitModal.stories.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import LtiLimitModal from './LtiLimitModal.jsx'; +import LtiLimitModal from './LtiLimitModal'; const Template = (args) => diff --git a/src/components/modal/LtiLimitModal.test.jsx b/src/components/modal/LtiLimitModal.test.jsx index 16d0aa2..32e8653 100644 --- a/src/components/modal/LtiLimitModal.test.jsx +++ b/src/components/modal/LtiLimitModal.test.jsx @@ -2,7 +2,7 @@ import React from 'react' import '@testing-library/jest-dom'; import {render, screen} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' -import LtiLimitModal from "./LtiLimitModal.jsx"; +import LtiLimitModal from "./LtiLimitModal"; describe('LtiLimitModal Test Suite', () => { diff --git a/src/components/modal/LtiLimitModal.jsx b/src/components/modal/LtiLimitModal.tsx similarity index 67% rename from src/components/modal/LtiLimitModal.jsx rename to src/components/modal/LtiLimitModal.tsx index 0e9d7b4..e8f3df5 100644 --- a/src/components/modal/LtiLimitModal.jsx +++ b/src/components/modal/LtiLimitModal.tsx @@ -3,17 +3,40 @@ */ import React from 'react' import { Modal } from '@instructure/ui-modal' -import { LtiHeightLimitContext } from '../heightLimit/LtiHeightLimit.jsx' +import { LtiHeightLimitContext } from '../heightLimit/LtiHeightLimit' import PropTypes from 'prop-types' +type LtiLimitModalProps = { + label?: string + debug?: boolean + onOpen?: () => void + onClose?: () => void + children?: React.ReactNode + [key: string]: unknown +} + /** * This correctly places Modals when displayed in an iframe. */ -export class LtiLimitModal extends React.Component { +export class LtiLimitModal extends React.Component { static contextType = LtiHeightLimitContext + static propTypes = { + label: PropTypes.string, + debug: PropTypes.bool + } + + static defaultProps = { + label: 'Modal', + onOpen: () => {}, + onClose: () => {}, + debug: false + } + declare context: React.ContextType + + opened = false - logDebug(message) { + logDebug(message: string) { if (this.props.debug) { console.log(message) } @@ -32,14 +55,14 @@ export class LtiLimitModal extends React.Component { this.logDebug('Opened Modal') this.opened = true this.context.set(true) - this.props.onOpen() + this.props.onOpen?.() } onClose = () => { this.logDebug('Closed Modal') this.opened = false this.context.set(false) - this.props.onClose() + this.props.onClose?.() } render() { @@ -57,16 +80,4 @@ export class LtiLimitModal extends React.Component { } } -LtiLimitModal.propTypes = { - label: PropTypes.string, - debug: PropTypes.bool -} - -LtiLimitModal.defaultProps = { - label: 'Modal', - onOpen: () =>{}, - onClose: () =>{}, - debug: false -} - -export default LtiLimitModal \ No newline at end of file +export default LtiLimitModal diff --git a/src/components/promptOAuth/PromptOAuth.stories.jsx b/src/components/promptOAuth/PromptOAuth.stories.jsx index 702e46d..9dd3847 100644 --- a/src/components/promptOAuth/PromptOAuth.stories.jsx +++ b/src/components/promptOAuth/PromptOAuth.stories.jsx @@ -1,5 +1,5 @@ import React, { Fragment } from 'react' -import PromptOAuth from "./PromptOAuth.jsx"; +import PromptOAuth from "./PromptOAuth"; export default { title: 'Components/PromptOAuth', diff --git a/src/components/promptOAuth/PromptOAuth.test.jsx b/src/components/promptOAuth/PromptOAuth.test.jsx index 6a97fec..a13711c 100644 --- a/src/components/promptOAuth/PromptOAuth.test.jsx +++ b/src/components/promptOAuth/PromptOAuth.test.jsx @@ -2,7 +2,7 @@ import React from 'react' import '@testing-library/jest-dom'; import {render, screen} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' -import PromptOAuth from "./PromptOAuth.jsx"; +import PromptOAuth from "./PromptOAuth"; describe('PromptOAuth Test Suite', () => { diff --git a/src/components/promptOAuth/PromptOAuth.jsx b/src/components/promptOAuth/PromptOAuth.tsx similarity index 93% rename from src/components/promptOAuth/PromptOAuth.jsx rename to src/components/promptOAuth/PromptOAuth.tsx index a9ecf68..bff9a33 100644 --- a/src/components/promptOAuth/PromptOAuth.jsx +++ b/src/components/promptOAuth/PromptOAuth.tsx @@ -6,10 +6,18 @@ import {View} from "@instructure/ui-view"; import {Button} from "@instructure/ui-buttons"; import {InstUISettingsProvider} from "@instructure/emotion"; +type PromptOAuthProps = { + accessToken?: string + tokenGranted: () => void + needsGrant: boolean + server: string + toolName?: string +} + /** * Displays a modal asking the user to grant access when requested to. */ -export class PromptOAuth extends React.Component { +export class PromptOAuth extends React.Component { static propTypes = { /** @@ -91,4 +99,4 @@ export class PromptOAuth extends React.Component { } } -export default PromptOAuth \ No newline at end of file +export default PromptOAuth diff --git a/src/components/tokenRetriever/LtiTokenRetriever.stories.jsx b/src/components/tokenRetriever/LtiTokenRetriever.stories.jsx index 6afd75f..1a2969c 100644 --- a/src/components/tokenRetriever/LtiTokenRetriever.stories.jsx +++ b/src/components/tokenRetriever/LtiTokenRetriever.stories.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import LtiTokenRetriever from './LtiTokenRetriever.jsx'; +import LtiTokenRetriever from './LtiTokenRetriever'; import {http, delay, HttpResponse} from "msw"; const Template = (args) => diff --git a/src/components/tokenRetriever/LtiTokenRetriever.test.jsx b/src/components/tokenRetriever/LtiTokenRetriever.test.jsx index aaf8a0f..35353dc 100644 --- a/src/components/tokenRetriever/LtiTokenRetriever.test.jsx +++ b/src/components/tokenRetriever/LtiTokenRetriever.test.jsx @@ -2,7 +2,7 @@ import React from 'react' import '@testing-library/jest-dom'; import {render, screen} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' -import LtiTokenRetriever from "./LtiTokenRetriever.jsx"; +import LtiTokenRetriever from "./LtiTokenRetriever"; import {setupServer} from "msw/node"; import {delay, http, HttpResponse} from "msw"; diff --git a/src/components/tokenRetriever/LtiTokenRetriever.jsx b/src/components/tokenRetriever/LtiTokenRetriever.tsx similarity index 80% rename from src/components/tokenRetriever/LtiTokenRetriever.jsx rename to src/components/tokenRetriever/LtiTokenRetriever.tsx index eb70931..fabe64d 100644 --- a/src/components/tokenRetriever/LtiTokenRetriever.jsx +++ b/src/components/tokenRetriever/LtiTokenRetriever.tsx @@ -1,7 +1,21 @@ import React, { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { Spinner } from '@instructure/ui-spinner'; -import ErrorBillboard from "../errorBillboard/ErrorBillboard.jsx"; +import ErrorBillboard from "../errorBillboard/ErrorBillboard"; + +type TokenRetrieverState = { + loading: boolean + error: string | null +} + +type LocationLike = Pick + +type LtiTokenRetrieverProps = { + ltiServer?: string | null + handleJwt: (jwt: string, server: string) => void + children: React.ReactNode + location?: LocationLike +} /** * Looks for a one time token in the URL parameters and then attempts to use this to retrieve a JWT token @@ -11,8 +25,8 @@ import ErrorBillboard from "../errorBillboard/ErrorBillboard.jsx"; * - no token in the URL * - token cannot be retrieved */ -export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = window.location }) => { - const [state, setState] = useState({ +export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = window.location }: LtiTokenRetrieverProps) => { + const [state, setState] = useState({ loading: true, error: null }); @@ -59,12 +73,16 @@ export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = w const json = await response.json(); const jwt = json.jwt || json.token_value; + if (!jwt) { + throw new Error("Failed to load token."); + } - handleJwt(jwt, server); - saveJwt(jwt); - setState({ loading: false, error: null }); + handleJwt(jwt, server); + saveJwt(jwt); + setState({ loading: false, error: null }); } catch (error) { - setState({ loading: false, error: error.message }); + const message = error instanceof Error ? error.message : "Failed to load token."; + setState({ loading: false, error: message }); } }; @@ -104,7 +122,7 @@ export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = w return server ? decodeURIComponent(server) : null; }; - const saveJwt = (jwt) => { + const saveJwt = (jwt: string | null) => { if (!jwt) return; try { @@ -121,12 +139,14 @@ export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = w } }; - const loadJwt = () => { + const loadJwt = (): string | null => { try { - const data = JSON.parse(sessionStorage.getItem('jwt')); + const stored = localStorage.getItem('jwt'); + if (!stored) return null; + const data = JSON.parse(stored); if (!data) return null; - return data.token; + return data.token ?? null; } catch (e) { if (!(e instanceof DOMException)) { throw e; @@ -169,4 +189,4 @@ LtiTokenRetriever.propTypes = { location: PropTypes.object }; -export default LtiTokenRetriever; \ No newline at end of file +export default LtiTokenRetriever; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 4a511dc..0000000 --- a/src/index.js +++ /dev/null @@ -1,10 +0,0 @@ - -// components -export * from './components/heightLimit/LtiHeightLimit.jsx'; -export * from './components/modal/LtiLimitModal.jsx'; -export * from './components/tokenRetriever/LtiTokenRetriever.jsx'; -export * from './components/errorBillboard/ErrorBillboard.jsx'; -export * from './components/LtiPageSettings/LtiPageSettings.jsx'; -export * from './components/launchOAuth/LaunchOAuth.jsx'; -export * from './components/applyTheme/LtiApplyTheme.jsx'; -export * from './components/promptOAuth/PromptOAuth.jsx'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..70e9a67 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ + +// components +export * from './components/heightLimit/LtiHeightLimit'; +export * from './components/modal/LtiLimitModal'; +export * from './components/tokenRetriever/LtiTokenRetriever'; +export * from './components/errorBillboard/ErrorBillboard'; +export * from './components/LtiPageSettings/LtiPageSettings'; +export * from './components/launchOAuth/LaunchOAuth'; +export * from './components/applyTheme/LtiApplyTheme'; +export * from './components/promptOAuth/PromptOAuth'; diff --git a/src/types/instructure-ui.d.ts b/src/types/instructure-ui.d.ts new file mode 100644 index 0000000..4cbab54 --- /dev/null +++ b/src/types/instructure-ui.d.ts @@ -0,0 +1,9 @@ +declare module '@instructure/emotion'; +declare module '@instructure/ui-themes'; +declare module '@instructure/ui-modal'; +declare module '@instructure/ui-spinner'; +declare module '@instructure/ui-billboard'; +declare module '@instructure/ui-icons'; +declare module '@instructure/ui-heading'; +declare module '@instructure/ui-view'; +declare module '@instructure/ui-buttons'; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9849b37 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": [ + "src/**/*.test.*", + "src/**/*.spec.*", + "src/**/*.stories.*" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9b6d280 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "storybook-static"] +} diff --git a/vite.config.js b/vite.config.js index 643c560..ddf580f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -16,7 +16,7 @@ export default defineConfig({ // As this library will be re-compiled by the host application, we don't need to minify it. minify: false, lib: { - entry: 'src/index.js', + entry: 'src/index.ts', formats: ['es', 'cjs'] }, rollupOptions: {