Hello,
I would like to use the Forge bridge modal provided in this link.
However, the examples given there are quite limited. I’m using Custom UI, and I previously had a working modal implementation. Unfortunately, the modal I was using has been deprecated, so I need to switch to this one, but I haven’t been able to fully integrate it into my code.
I’ll share my example code below.
manifest.yml
jira:customFieldType:
- key: single-issue-picker-field
name: Single Issue Picker
icon: https://i.ibb.co/pr22Bhd/issue-picker.png
description: An issue single select field
viewportSize: medium
type: string
render: native
validation:
expression: |-
let errorMessage = value ? value.includes("64ab65a0-f5de-49b7-8c85-b8b921a91054") : false;
errorMessage ? value.split("/")[1].slice(0) : true
contextConfig:
resource: config-issue-picker
resource: proxy
edit:
resource: edit-issue-picker-res
resolver:
function: resolver
resources:
- key: edit-issue-picker-res
path: static/issue-picker-edit/build
tunnel:
port: 3000
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App/>
</React.StrictMode>,
document.getElementById('root')
);
App.js
import Button, { ButtonGroup, LoadingButton } from "@atlaskit/button";
import Lozenge from "@atlaskit/lozenge";
import Spinner from '@atlaskit/spinner';
import Form, {
ErrorMessage,
Field,
FormFooter,
FormSection,
HelperMessage
} from "@atlaskit/form";
import CheckIcon from '@atlaskit/icon/glyph/check'
import CrossIcon from '@atlaskit/icon/glyph/cross'
import { invoke, router, view, requestJira } from "@forge/bridge";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Select, { AsyncSelect } from '@atlaskit/select';
import { FONT_FAMILY } from "./lib/constants";
import { ISSUE_VIEW_CONTEXT, LICENSE_INVALID } from "./constants";
import { createIssueLink, deleteIssueLink, getIssueLinkId } from "./lib/issueLinkOperations";
import { getAllIssuesMatchingJQL, getIssuesMatchingJQL } from "./lib/getIssuesMatchingJQL";
import { getCustomFieldContext } from "./lib/getCustomFieldContext";
function App() {
const [issues, setIssues] = useState(null);
const [extension, setExtension] = useState(null);
const [fieldType, setFieldType] = useState(null);
const [fieldContext, setFieldContext] = useState(null);
const [licenseStatus, setLicenseStatus] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [fieldValue, setFieldValue] = useState(null);
const [newSelectedValues, setNewSelectedValues] = useState(null);
const [isLoadingIssues, setIsLoadingIssues] = useState(false);
useEffect(() => {
invoke("get-license-status").then((licenseStatus) => {
setLicenseStatus(licenseStatus);
})
}, []);
useEffect(() => {
view.getContext().then((context) => {
const { extension } = context;
setExtension(extension);
getCustomFieldContext(extension.fieldId, extension.issue.typeId, extension.project.id).then(data => {
if (data && data?.length > 0 && data[0].id) {
invoke("getCustomFieldConfigurationDetails", { fieldId: extension.fieldId, id: data[0].id }).then((result) => {
setFieldContext(result);
})
} else {
setFieldContext(null)
}
})
setNewSelectedValues(extension?.fieldValue ?? "");
setFieldValue(extension?.fieldValue ?? "");
setFieldType(extension?.fieldType ?? "");
});
}, []);
useEffect(() => {
if (fieldContext) getIssues(null);
}, [JSON.stringify(fieldContext)]);
const getIssues = useCallback(async (inputValue) => {
let predefinedJql;
if (newSelectedValues && fieldContext) {
if (typeof newSelectedValues === "string") {
predefinedJql = `key in (${newSelectedValues})`;
} else if (newSelectedValues.length > 0) {
predefinedJql = `key in (${newSelectedValues.join(',')})`;
}
}
if (fieldContext) {
let jql = "";
if (fieldContext?.jql) {
jql = `${fieldContext?.jql}`;
}
if (fieldContext?.limitIssuesUnderCurrentProject) {
jql += ` ${fieldContext?.jql ? "AND" : ""} project = ${extension?.project?.key}`;
}
if (jql && inputValue) jql += ` AND (summary ~ "${inputValue}")`
if (predefinedJql) {
if (jql) {
jql += ` OR ${predefinedJql}`
} else {
jql = predefinedJql
}
}
setIsLoadingIssues(true);
setIssues(null);
const data = await getIssuesMatchingJQL(0, 100, jql, `summary,status`, true);
const options = data.map((e) => ({ value: e.key, label: e.key + ": " + e.fields?.summary, status: e.fields?.status }));
setIssues(options);
setIsLoadingIssues(false);
return options;
}
return []
}, [JSON.stringify(fieldContext), JSON.stringify(extension), JSON.stringify(newSelectedValues)]);
async function createIssueLinkTowards(inwardIssue) {
if (fieldContext?.selectedIssueLinkType) {
const createLinkResponse = await createIssueLink(inwardIssue, extension?.issue?.key, fieldContext?.selectedIssueLinkType?.value);
if (!createLinkResponse.ok) {
return "Failed to create issue link from " + issueKey + " to " + inwardIssue + ": " + ((await createLinkResponse.json())?.errorMessages ?? []).join(",");
}
}
return null;
}
async function removeIssueLinkTowards(oldInwardIssue) {
if (fieldContext?.deleteLinksWhenUnselected) {
const linkID = await getIssueLinkId(extension?.issue?.key, oldInwardIssue, fieldContext?.selectedIssueLinkType?.value);
if (linkID) {
const deleteLinkResponse = await deleteIssueLink(linkID);
if (!deleteLinkResponse.ok) {
return ("Failed to delete issue link from " + issueKey + " to " + oldInwardIssue + ": " + ((await deleteLinkResponse.json())?.errorMessages ?? []).join(","));
}
}
}
return null;
}
const onSubmit = async (values) => {
setIsSubmitting(true);
let flag = false;
if (fieldContext?.selectedIssueLinkType?.value) {
if (fieldType.includes("multi")) {
if (fieldValue && newSelectedValues) {
// adding issue links to newly selected issues
const oldInwardIssues = fieldValue;
const newInwardIssues = newSelectedValues;
for (const inwardIssue of newInwardIssues) {
if (!oldInwardIssues.includes(inwardIssue)) {
const resp = await createIssueLinkTowards(inwardIssue);
flag = resp ? resp : flag;
}
}
// deleting issue links from unselected issues
for (const oldInwardIssue of oldInwardIssues) {
if (!newInwardIssues.includes(oldInwardIssue)) {
const resp = await removeIssueLinkTowards(oldInwardIssue);
flag = resp ? resp : flag;
}
}
} else if (fieldValue) {
// removing all links because all issues are unselected
const oldInwardIssues = fieldValue;
for (const oldInwardIssue of oldInwardIssues) {
const resp = await removeIssueLinkTowards(oldInwardIssue);
flag = resp ? resp : flag;
}
} else {
// adding links to all selected issues because there was no value before
const newInwardIssues = newSelectedValues;
for (const inwardIssue of newInwardIssues) {
const resp = await createIssueLinkTowards(inwardIssue);
flag = resp ? resp : flag;
}
}
} else {
if (fieldValue && newSelectedValues) {
// when the selected issue has changed, delete the old link
if (fieldValue !== newSelectedValues) {
const resp = await removeIssueLinkTowards(fieldValue);
flag = resp ? resp : flag;
}
// link the newly selected issue
const resp = await createIssueLinkTowards(newSelectedValues);
flag = resp ? resp : flag;
} else if (fieldValue) {
// delete the old link, as the select is now cleared
const resp = await removeIssueLinkTowards(fieldValue);
flag = resp ? resp : flag;
} else {
const resp = await createIssueLinkTowards(newSelectedValues);
flag = resp ? resp : flag;
}
}
}
if (flag) {
return constructUIKitErrorMessage(fieldType.includes("multi") ? [flag] : flag);
}
return await view.submit(newSelectedValues).then(() => setIsSubmitting(false));
};
if (!extension || !fieldContext || licenseStatus === null) {
return <div style={{ display: "flex", justifyContent: "center", minHeight: 300, alignItems: "center" }}><Spinner size="large" /></div>;
}
//TODO: burdaki link vs de?i?ecek
if (Object.keys(fieldContext).length === 0)
return <>Not configured!
<a style={{ color: "blue", textDecoration: "underline" }} onClick={() => router.open("https://werare.atlassian.net/wiki/spaces/ACF/pages/769262361/Single+Multi+Issue+Picker")}>
Click here to learn how to configure the field.
</a>
</>
if (licenseStatus === false) return <>{LICENSE_INVALID}</>
return (
<React.Fragment>
<Form>
{({ formProps, dirty, submitting }) => (
<>
<h1><span style={{
fontFamily: FONT_FAMILY,
fontSize: 20,
fontStyle: "inherit",
fontWeight: 500
}}>{extension?.fieldName}</span></h1>
<form {...formProps}>
<HelperMessage>Please type the issue summary to search for issues.</HelperMessage>
<Select
isLoading={isLoadingIssues}
isClearable={true}
label="Issues"
name="selectedIssues"
defaultOptions={issues ?? []}
loadOptions={async (inputvalue) => await getIssues(inputvalue)}
isMulti={fieldType.includes("multi")}
onChange={(e) => {
setNewSelectedValues(fieldType.includes("multi") ? (e ?? []).map((a) => a.value) : e?.value ? e?.value : null)
}}
placeholder={"Type the summary of the issue"}
value={fieldType.includes("multi") ? (issues ?? []).filter((e) => (newSelectedValues ?? []).includes(e.value)) : (issues ?? []).find((e) => e.value === (newSelectedValues))}
/>
<FormFooter align="start">
<div className="buttonGroup"
style={{
fontSize: 14,
fontFamily: FONT_FAMILY,
fontStyle: "normal",
display: "flex",
width: "100%",
justifyContent: "end",
margin: "0 1.5em 0 1.5em"
}}>
<Button
appearance="subtle"
onClick={view.close}
>
Cancel
</Button>
<LoadingButton
isLoading={isSubmitting}
onClick={onSubmit}
appearance="primary"
isDisabled={submitting}
style={{ marginLeft: 10 }}
>
Submit
</LoadingButton>
</div>
</FormFooter>
</form>
</>
)}
</Form>
</React.Fragment>
);
}
export default App;
Thank you.