@valiantys/atlassian-app-frontend
v1.0.0
Published
This library provides an Atlassian Forge Custom UI wrapper component that handles all the setup necessary to support an app that can run deployed or in standalone mode
Downloads
72
Readme
@valiantys/atlassian-app-frontend
This library provides an Atlassian Forge Custom UI wrapper component that handles all the setup necessary to support an app that can run deployed or in standalone mode.
Using the library
The library can be installed into a React Custom UI Forge App that is built with Vite. It definitely does not work with react-scripts. It has not been tested with Webpack.
Table of Contents
- Generate a new Forge App
- Creating a Custom UI module with Vite
- Using provided abstractions for @forge/bridge APIs
- Invoking backend resolver functions (invoke)
- requestJira, requestConfluence, requestBitbucket
- Accessing JSM Assets APIs that require a workspace ID
- Accessing the View Context with useViewContext hook
- Host Router (@forge/bridge router)
- Modals (@forge/bridge Modal)
- Invoking remote calls from the client (@forge/bridge invokeRemote)
- Unit testing
- Example Application
- Support
Generate a new Forge App
See the README file in the @valiantys/atlassian-app-backend library for instructions on creating a Forge app that supports standalone and deployed modes.
Creating a Custom UI module with Vite
Delete the static/hello-world app that was generated by the forge cli, then follow the steps below to add a Custom UI app with Vite.
Generate the Custom UI app
Run Vite CLI command to generate a new react app.
cd static
npm create vite@latest my-module-name -- --template react-ts
Install the library
cd my-module-name
npm i @valiantys/atlassian-app-frontend
Update the generated code
You can delete the App.css file, then replace the contents of main.tsx, App.tsx, and index.css as shown below.
main.tsx
import '@atlaskit/css-reset';
import * as ReactDOM from 'react-dom/client';
import './index.css';
import App from './app';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);
app.tsx
import { AtlassianApp } from '@valiantys/atlassian-app-frontend';
function App() {
return (
<AtlassianApp appName="hello" standaloneConfig={{}} embeddedConfig={{}}>
Hello World
</AtlassianApp>
);
}
export default App;
index.css
@import '../node_modules/@valiantys/atlassian-app-frontend/style.css';
Vite config
Add base: './' to the vite.config.ts
export default defineConfig({
base: './',
server: {
port: 4200,
host: 'localhost',
},
preview: {
port: 4300,
host: 'localhost',
},
plugins: [react()],
});
manifest.yml
Add/change resource path to point to the dist directory of your new Custom UI app. You also need to allow inline styles.
resources:
- key: main
path: static/my-module-name/dist
permissions:
content:
styles:
- 'unsafe-inline'
Setup OAuth for invoking Atlassian APIs
Add OAuth config to a .env file
Create an OAuth 2.0 integration app at https://developer.atlassian.com/console/myapps/ with the read:me
scope activated under User identity API
and the authorization callback url set to http://localhost:4200/callback
.
Here, you will also need to add any additional scopes needed for Atlassian APIs that you use.
From the developer console, you will need to copy the client ID and client secret values that are found on the Settings page of the OAuth app you create.
The secret will go in the backend .env file. You should only include the client ID in the frontend .env file as shown below.
Your .env file will contain secrets that should NOT be committed to git. You should also add a sample.env file to your project that does NOT contain any secrets but makes it easier for other developers to set up their own .env file.
static/my-module-name/.env
# Needed for standalone authentication with Atlassian OAuth
VITE_ATLASSIAN_OAUTH_CLIENT_ID=<client-id>
# Set to deployed url/port in non local environment
VITE_STANDALONE_BACKEND_URL='http://localhost'
VITE_STANDALONE_BACKEND_PORT=3000
Add OAuth config to AtlassianApp props:
The oAuthScopes listed here must match exactly with the OAuth scopes granted for the OAuth client id supplied in the .env file. The configuration below contains the OAuth scopes needed for all the provided code examples.
app.tsx
const backendUrl = `${import.meta.env.VITE_STANDALONE_BACKEND_URL}:${import.meta.env.VITE_STANDALONE_BACKEND_PORT}/api/`;
...
<AtlassianApp
appName="hello"
standaloneConfig={{
oauthConfig: {
appName: 'hello',
codeTokenExchangeUrl: `${backendUrl}${CommonResolverPaths.getOauthToken}`,
clientId: import.meta.env.VITE_ATLASSIAN_OAUTH_CLIENT_ID,
oAuthScopes: [
'read:jira-user',
'read:me',
'read:jira-work', // needed to query for Jira issues
'read:servicedesk-request', // needed to query for assets workspace ID
'read:cmdb-schema:jira', // needed to query for list of assets schemas
],
},
}}
embeddedConfig={{}}
>
Run the app locally (standalone mode)
cd static/my-module-name
npm run dev
Deploy the app
cd static/my-module-name
npm run build
cd ../../
forge deploy
Using provided abstractions for @forge/bridge APIs
Invoking backend resolver functions (invoke)
Configuration
static/my-module-name/.env
# Set to deployed url/port in non local environment
VITE_STANDALONE_BACKEND_URL='http://localhost'
VITE_STANDALONE_BACKEND_PORT=3000
app.tsx
const backendUrl = `${import.meta.env.VITE_STANDALONE_BACKEND_URL}:${import.meta.env.VITE_STANDALONE_BACKEND_PORT}/api/`;
function App() {
return (
<AtlassianApp
appName="hello"
standaloneConfig={{
backendUrl,
Example Component
import { useEffect, useState } from 'react';
import { useBackendAdapter } from '@valiantys/atlassian-app-frontend';
export function Hello() {
const [text, setText] = useState<string>('');
const { invoke } = useBackendAdapter();
useEffect(() => {
invoke<{ text: string }>('getText').then((t) => setText(t.text));
}, [invoke]);
return <div>{text}</div>;
}
requestJira, requestConfluence, requestBitbucket
The library provides hooks to access abstractions for each one of these. Using these abstractions is what allows the frontend app to run in both standalone and deployed modes. When deployed in Forge, these abstractions are just simple wrappers around the @forge/bridge methods. In standalone mode, however, they make requests using the OAuth token and the fetch API. See Setup OAuth for invoking Atlassian APIs.
Example component with useRequestJira
This component uses the Jira REST API to retrieve the list of Issue Types that the user has permissions to view.
import { useEffect, useState } from 'react';
import { useRequestJira } from '@valiantys/atlassian-app-frontend';
export function IssueTypesExample() {
const [issueTypes, setIssueTypes] = useState<IssueTypeDetails[]>([]);
const requestJiraSvc = useRequestJira();
useEffect(() => {
requestJiraSvc
.fetch<IssueTypeDetails[]>({
url: requestJiraSvc.route`/rest/api/2/issuetype`,
method: 'GET',
})
.then((types) => setIssueTypes(types));
}, [requestJiraSvc]);
return (
<div>
{issueTypes.map((it) => (
<div key={it.id}>
{it.id}: {it.description}
</div>
))}
</div>
);
}
interface IssueTypeDetails {
avatarId: number;
description: string;
hierarchyLevel: number;
iconUrl: string;
id: string;
name: string;
self: string;
subtask: boolean;
}
Accessing JSM Assets APIs that require a workspace ID
The JSM Assets APIs require a workspace ID in addition to the cloud ID of the site. The library can handle the details of getting the workspace ID for you and will then provide that to your components. You can access the workspaceId using the useWorkspaceId hook. OAuth configuration is required for standalone mode, see Setup OAuth for invoking Atlassian APIs.
Turn on workspace checking
<AtlassianApp
appName="hello"
doCheckWorkspace={true} // Needed for querying JSM Assets
Example component with useWorkspaceId
This example also demonstrates use of the useLoadDataEffect hook and the PageLoadingView component. These help implement the common pattern of loading data on first component render.
import { useCallback } from 'react';
import { PageLoadingView, useLoadDataEffect, useRequestJira, useWorkspaceId } from '@valiantys/atlassian-app-frontend';
interface AssetSchema {
name: string;
id: string;
}
export function ListAssets() {
const requestJiraSvc = useRequestJira();
const workspaceId = useWorkspaceId();
const listAssetsSchemas = useCallback(async () => {
if (requestJiraSvc && workspaceId) {
const paginatedResult = await requestJiraSvc.fetch<{
values: AssetSchema[];
}>({
url: requestJiraSvc.route`/jsm/assets/workspace/${workspaceId}/v1/objectschema/list`,
method: 'GET',
});
return paginatedResult.values;
}
return [];
}, [requestJiraSvc, workspaceId]);
const { data, loading, error } = useLoadDataEffect<AssetSchema[]>(listAssetsSchemas);
return loading || error ? <PageLoadingView label="" loadingError={error} /> : <div>{data?.map((schema) => <div key={schema.id}>{schema.name}</div>)}</div>;
}
Accessing the View Context with useViewContext hook
The useViewContext hook gives access to the Atlassian view context, which provides information about the module itself and the Atlassian page in which it is loaded. When running in standalone mode, you may configure the context with hard-coded values for testing.
Example
In this example, the standaloneConfig contains issue and project data for a module that would normally be loaded in the context of a Jira issue.
App.tsx
<AtlassianApp
standaloneConfig={{
initialMockViewContext: {
extension: {
issue: {
key: 'BST-18',
id: 'BST-18',
type: 'Submit a request or incident',
typeId: '10013',
},
project: {
id: '10002',
key: 'FMSR',
type: 'service_desk',
},
type: 'jira:issueContext',
},
},
Accessing the View Context from a component
import { useViewContext } from '@valiantys/atlassian-app-frontend';
import { useEffect, useState } from 'react';
export function ViewContextExample() {
const viewContext = useViewContext();
const [contextData, setContextData] = useState<string | undefined>();
useEffect(() => {
if (viewContext) {
viewContext.getContext().then((fullContext) => setContextData(JSON.stringify(fullContext)));
}
}, [viewContext]);
return <div>{contextData}</div>;
}
Host Router (@forge/bridge router)
The router provides methods for opening a new window, navigating the host application window to a new location, and for reloading the host window (https://developer.atlassian.com/platform/forge/custom-ui-bridge/router/). In standalone mode, the host window is your app window. When deployed, the host window would be whatever Atlassian product window the module is running in.
import { useHostRouter } from '@valiantys/atlassian-app-frontend';
import Button from '@atlaskit/button/new';
export function HostRouterExample() {
const hostRouter = useHostRouter();
return (
<div>
<Button onClick={() => void hostRouter.open('http://example.com/')}>Navigate</Button>
</div>
);
}
Modals (@forge/bridge Modal)
The forge bride API provides the ability to open modal windows within your module (https://developer.atlassian.com/platform/forge/custom-ui-bridge/modal/). In standalone mode, your modal will simply be opened in a new browser window. A current limitation of standalone mode is that the modal will not receive data from the opening module, this must be instead hard-coded into the standalone config on the modal application.
Example Modal Opener
The App.tsx config must contain the URL of the modal app that is also running locally. If the app opens multiple modal resources, you may provide a mapping of resource name to URL.
<AtlassianApp
appName="hello"
standaloneConfig={{
modalOpenerConfig: { defaultUrl: 'http://localhost:4201/' },
}}
Component that opens a modal resource.
import Button from '@atlaskit/button/new';
import Textfield from '@atlaskit/textfield';
import { useModalService } from '@valiantys/atlassian-app-frontend';
import { useCallback, useState } from 'react';
export function OpenModalExample() {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [text, setText] = useState<string>('Hello');
const modalService = useModalService();
const openModal = useCallback(() => {
if (!isOpen) {
setIsOpen(true);
void modalService.open({
resource: 'hello-world-modal', // must be the key of a resource defined in manifest.yml
onClose: () => {
setIsOpen(false);
},
/*
small - w: 400px h: 20vh minHeight: 320px
medium - w: 600px h: 40vh minHeight: 520px
large - w: 800px h: 70vh minHeight: 720px
xlarge - w: 968px h: 90vh
max - w: 100% h: 100%
*/
size: 'large',
context: {
text,
},
closeOnEscape: true,
closeOnOverlayClick: true,
});
}
}, [modalService, isOpen, text]);
return (
<div>
<Textfield value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)}></Textfield>
<Button onClick={openModal}>Open Modal</Button>
</div>
);
}
Example Modal Content Application
App.tsx
import { AtlassianApp, ModalContent } from '@valiantys/atlassian-app-frontend';
import { Hello } from './hello';
function App() {
return (
<AtlassianApp
appName="hello-modal"
standaloneConfig={{
modalContextConfig: { openerOrigin: 'http://localhost:4200' },
initialMockViewContext: {
extension: {
// This is a hard-coded version of the data that will be passed
// from the opening app when running in an Atlassian environment.
modal: {
text: 'Hello Bob',
},
},
},
}}
embeddedConfig={{}}
>
{/* ModalContent component provides modal-related services, see Hello component for example usage. */}
<ModalContent>
<Hello></Hello>
</ModalContent>
</AtlassianApp>
);
}
export default App;
Content Component
import Button from '@atlaskit/button/new';
import { useModalContentService } from '@valiantys/atlassian-app-frontend';
export function Hello() {
const { modalContextData, close } = useModalContentService<{
text: string;
}>();
return (
<div>
<div>{modalContextData?.text}</div>
<Button onClick={close}>Close Modal</Button>
</div>
);
}
Invoking remote calls from the client (@forge/bridge invokeRemote)
Instead of invoking call on the Forge backend, an app may invoke calls on a remote backend that is configured in the app's manifest file.
App config
<AtlassianApp
appName="hello-remote"
standaloneConfig={{
remoteUrl: 'https://jsonplaceholder.typicode.com/', // For invokeRemote from client (useRemoteAdapter)
}}
Component
/**
* manifest.yml must contain this configuration to enable the remote invocation:
*
* remotes:
* - key: example-remote
* baseUrl: https://jsonplaceholder.typicode.com
* permissions:
* external:
* fetch:
* client:
* - remote: example-remote
*/
import { useRemoteAdapter } from '@valiantys/atlassian-app-frontend';
import { useEffect, useState } from 'react';
interface Todo {
completed: boolean;
id: number;
title: string;
userId: number;
}
export function InvokeRemoteExample() {
const remoteSvc = useRemoteAdapter();
const [todo, setTodo] = useState<Todo | undefined>(undefined);
useEffect(() => {
remoteSvc
.invokeRemote<Todo, void>({
path: '/todos/1',
method: 'GET',
headers: { accept: 'application/json' },
})
.then((response) => setTodo(response.body))
.catch((error) => console.error('remote error', error));
}, [remoteSvc]);
return <div>Todo: {todo?.title}</div>;
}
Unit testing
Add react and jest testing support
cd static/my-module-name
npm i -D jest ts-jest @types/jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-fixed-jsdom identity-obj-proxy
npx ts-jest config:init
Configure Jest
Make changes to static/my-module-name/jest.config.js:
- Change testEnvironment to "jest-fixed-jsdom"
- Add tsConfig path to transform
- Add moduleNameMapper to prevent errors with Jest trying to interpret compiled css files
export default {
testEnvironment: 'jest-fixed-jsdom', // https://github.com/mswjs/jest-fixed-jsdom
transform: {
'^.+.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.app.json' }],
},
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy',
},
};
Example
Component
import { useEffect, useState } from 'react';
import { useBackendAdapter } from '@valiantys/atlassian-app-frontend';
export function Hello() {
const [text, setText] = useState<string>('');
const { invoke } = useBackendAdapter();
useEffect(() => {
invoke<{ text: string }>('getText').then((t) => setText(t.text));
}, [invoke]);
return <div>{text}</div>;
}
Test
import { act, render, screen } from '@testing-library/react';
import { AtlassianAppTest, AtlassianAppTestProps, defaultProps } from '@valiantys/atlassian-app-frontend';
import { Hello } from './hello';
describe('HelloWorld', () => {
it('should render successfully', async () => {
await act(async () => {
// Setup mocks
const mockInvoke = jest.fn();
mockInvoke.mockReturnValue(Promise.resolve({ text: 'Hello World' }));
const appProps: AtlassianAppTestProps = {
...defaultProps(jest.fn),
invoke: mockInvoke,
};
// Render the component (which calls invoke on first render)
render(
<AtlassianAppTest {...appProps}>
<Hello></Hello>
</AtlassianAppTest>
);
});
// Throws error if text is not found within timeout period
await screen.findByText('Hello World');
});
});
References
- https://testing-library.com/docs/react-testing-library/example-intro
- https://testing-library.com/docs/queries/about/
Example Application
You will find an examples directory under node_modules/@valiantys/atlassian-app-frontend/examples, containing all the examples shown in this README along with more supporting files. The repo for the full example application is also available at https://bitbucket.org/oasisdigital/atlassian-app-examples.
If you would like to clone a template repo to get started more quickly, you can find one at https://bitbucket.org/oasisdigital/atlassian-app-template.
Support
Our issue-tracking board is viewable at https://trello.com/b/aRmPXQjq/atlassian-app-frontend-backend-npm-package-issue-tracker. To file an issue you may send it in an email to [email protected].
You may also contact us with questions at [email protected].