How to use Forge Modal

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.

@MertcanKaraba1 I haven’t figured out the best way to solve the problem but you will find a workaround for it over here.

1 Like