Implemented types context at Zustand and at the whole project
This commit is contained in:
parent
7f8f5d3f7b
commit
03d7020185
16 changed files with 143 additions and 168 deletions
|
|
@ -18,10 +18,10 @@ import {
|
|||
import { AuthContext } from "./contexts/authContext";
|
||||
import { FlowsContext } from "./contexts/flowsContext";
|
||||
import { locationContext } from "./contexts/locationContext";
|
||||
import { typesContext } from "./contexts/typesContext";
|
||||
import { getVersion } from "./controllers/API";
|
||||
import Router from "./routes";
|
||||
import useAlertStore from "./stores/alertStore";
|
||||
import { useTypesStore } from "./stores/typesStore";
|
||||
|
||||
export default function App() {
|
||||
let { setCurrent, setShowSideBar, setIsStackedOpen } =
|
||||
|
|
@ -43,8 +43,7 @@ export default function App() {
|
|||
const successOpen = useAlertStore((state) => state.successOpen);
|
||||
const setSuccessOpen = useAlertStore((state) => state.setSuccessOpen);
|
||||
const loading = useAlertStore((state) => state.loading);
|
||||
|
||||
const { fetchError } = useContext(typesContext);
|
||||
const fetchError = useAlertStore((state) => state.fetchError);
|
||||
|
||||
// Initialize state variable for the list of alerts
|
||||
const [alertsList, setAlertsList] = useState<
|
||||
|
|
@ -132,14 +131,15 @@ export default function App() {
|
|||
|
||||
const { isAuthenticated } = useContext(AuthContext);
|
||||
const { refreshFlows, setVersion } = useContext(FlowsContext);
|
||||
const { getTypes } = useContext(typesContext);
|
||||
const getTypes = useTypesStore((state) => state.getTypes);
|
||||
|
||||
useEffect(() => {
|
||||
// If the user is authenticated, fetch the types. This code is important to check if the user is auth because of the execution order of the useEffect hooks.
|
||||
if (isAuthenticated === true) {
|
||||
// get data from db
|
||||
refreshFlows();
|
||||
getTypes();
|
||||
getTypes().then(() => {
|
||||
refreshFlows();
|
||||
});
|
||||
}
|
||||
|
||||
getVersion().then((data) => {
|
||||
|
|
@ -156,16 +156,14 @@ export default function App() {
|
|||
}}
|
||||
FallbackComponent={CrashErrorComponent}
|
||||
>
|
||||
{loading ? (
|
||||
{fetchError ? (
|
||||
<FetchErrorComponent
|
||||
description={FETCH_ERROR_DESCRIPION}
|
||||
message={FETCH_ERROR_MESSAGE}
|
||||
></FetchErrorComponent>
|
||||
) : loading ? (
|
||||
<div className="loading-page-panel">
|
||||
{fetchError ? (
|
||||
<FetchErrorComponent
|
||||
description={FETCH_ERROR_DESCRIPION}
|
||||
message={FETCH_ERROR_MESSAGE}
|
||||
></FetchErrorComponent>
|
||||
) : (
|
||||
<LoadingComponent remSize={50} />
|
||||
)}
|
||||
<LoadingComponent remSize={50} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
TOOLTIP_EMPTY,
|
||||
} from "../../../../constants/constants";
|
||||
import { FlowsContext } from "../../../../contexts/flowsContext";
|
||||
import { typesContext } from "../../../../contexts/typesContext";
|
||||
import { undoRedoContext } from "../../../../contexts/undoRedoContext";
|
||||
import { postCustomComponentUpdate } from "../../../../controllers/API";
|
||||
import useAlertStore from "../../../../stores/alertStore";
|
||||
|
|
@ -48,6 +47,7 @@ import {
|
|||
nodeNames,
|
||||
} from "../../../../utils/styleUtils";
|
||||
import { classNames, groupByFamily } from "../../../../utils/utils";
|
||||
import { useTypesStore } from "../../../../stores/typesStore";
|
||||
|
||||
export default function ParameterComponent({
|
||||
left,
|
||||
|
|
@ -78,14 +78,15 @@ export default function ParameterComponent({
|
|||
|
||||
const groupedEdge = useRef(null);
|
||||
|
||||
const { setFilterEdge } = useContext(typesContext);
|
||||
const setFilterEdge = useTypesStore((state) => state.setFilterEdge);
|
||||
|
||||
let disabled =
|
||||
edges.some(
|
||||
(edge) =>
|
||||
edge.targetHandle === scapedJSONStringfy(proxy ? { ...id, proxy } : id)
|
||||
) ?? false;
|
||||
|
||||
const { data: myData } = useContext(typesContext);
|
||||
const myData = useTypesStore((state) => state.data);
|
||||
|
||||
const { takeSnapshot } = useContext(undoRedoContext);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import InputComponent from "../../components/inputComponent";
|
|||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { priorityFields } from "../../constants/constants";
|
||||
import { useSSE } from "../../contexts/SSEContext";
|
||||
import { typesContext } from "../../contexts/typesContext";
|
||||
import { undoRedoContext } from "../../contexts/undoRedoContext";
|
||||
import NodeToolbarComponent from "../../pages/FlowPage/components/nodeToolbarComponent";
|
||||
import useFlowStore from "../../stores/flowStore";
|
||||
|
|
@ -17,6 +16,7 @@ import { handleKeyDown, scapedJSONStringfy } from "../../utils/reactflowUtils";
|
|||
import { nodeColors, nodeIconsLucide } from "../../utils/styleUtils";
|
||||
import { classNames, cn, getFieldTitle } from "../../utils/utils";
|
||||
import ParameterComponent from "./components/parameterComponent";
|
||||
import { useTypesStore } from "../../stores/typesStore";
|
||||
|
||||
export default function GenericNode({
|
||||
data,
|
||||
|
|
@ -29,7 +29,7 @@ export default function GenericNode({
|
|||
xPos: number;
|
||||
yPos: number;
|
||||
}): JSX.Element {
|
||||
const { types } = useContext(typesContext);
|
||||
const types = useTypesStore((state) => state.types);
|
||||
const deleteNode = useFlowStore((state) => state.deleteNode);
|
||||
const setNode = useFlowStore((state) => state.setNode);
|
||||
const name = nodeIconsLucide[data.type] ? data.type : types[data.type];
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
getRandomDescription,
|
||||
getRandomName,
|
||||
} from "../utils/utils";
|
||||
import { typesContext } from "./typesContext";
|
||||
import { useTypesStore } from "../stores/typesStore";
|
||||
|
||||
const uid = new ShortUniqueId({ length: 5 });
|
||||
|
||||
|
|
@ -76,8 +76,7 @@ export function FlowsProvider({ children }: { children: ReactNode }) {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [flows, setFlows] = useState<Array<FlowType>>([]);
|
||||
const [tabsState, setTabsState] = useState<FlowsState>({});
|
||||
const { setData } = useContext(typesContext);
|
||||
|
||||
const setData = useTypesStore((state) => state.setData);
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const edges = useFlowStore((state) => state.edges);
|
||||
const reactFlowInstance = useFlowStore((state) => state.reactFlowInstance);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { AuthProvider } from "./authContext";
|
|||
import { FlowsProvider } from "./flowsContext";
|
||||
import { LocationProvider } from "./locationContext";
|
||||
|
||||
import { TypesProvider } from "./typesContext";
|
||||
import { UndoRedoProvider } from "./undoRedoContext";
|
||||
|
||||
export default function ContextWrapper({ children }: { children: ReactNode }) {
|
||||
|
|
@ -19,16 +18,14 @@ export default function ContextWrapper({ children }: { children: ReactNode }) {
|
|||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<ReactFlowProvider>
|
||||
<TypesProvider>
|
||||
<LocationProvider>
|
||||
<ApiInterceptor />
|
||||
<SSEProvider>
|
||||
<FlowsProvider>
|
||||
<UndoRedoProvider>{children}</UndoRedoProvider>
|
||||
</FlowsProvider>
|
||||
</SSEProvider>
|
||||
</LocationProvider>
|
||||
</TypesProvider>
|
||||
<LocationProvider>
|
||||
<ApiInterceptor />
|
||||
<SSEProvider>
|
||||
<FlowsProvider>
|
||||
<UndoRedoProvider>{children}</UndoRedoProvider>
|
||||
</FlowsProvider>
|
||||
</SSEProvider>
|
||||
</LocationProvider>
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAll, getHealth } from "../controllers/API";
|
||||
import useAlertStore from "../stores/alertStore";
|
||||
import { APIKindType } from "../types/api";
|
||||
import { typesContextType } from "../types/typesContext";
|
||||
import { AuthContext } from "./authContext";
|
||||
|
||||
//context to share types adn functions from nodes to flow
|
||||
|
||||
const initialValue: typesContextType = {
|
||||
types: {},
|
||||
setTypes: () => {},
|
||||
templates: {},
|
||||
setTemplates: () => {},
|
||||
data: {},
|
||||
setData: () => {},
|
||||
getTypes: () => {},
|
||||
setFetchError: () => {},
|
||||
fetchError: false,
|
||||
setFilterEdge: (filter) => {},
|
||||
getFilterEdge: [],
|
||||
};
|
||||
|
||||
export const typesContext = createContext<typesContextType>(initialValue);
|
||||
|
||||
export function TypesProvider({ children }: { children: ReactNode }) {
|
||||
const [types, setTypes] = useState({});
|
||||
const [templates, setTemplates] = useState({});
|
||||
const [data, setData] = useState({});
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const setLoading = useAlertStore((state) => state.setLoading);
|
||||
const [getFilterEdge, setFilterEdge] = useState([]);
|
||||
|
||||
async function getTypes(): Promise<void> {
|
||||
// We will keep a flag to handle the case where the component is unmounted before the API call resolves.
|
||||
let isMounted = true;
|
||||
try {
|
||||
const result = await getAll();
|
||||
// Make sure to only update the state if the component is still mounted.
|
||||
if (isMounted && result?.status === 200) {
|
||||
setLoading(false);
|
||||
let { data } = _.cloneDeep(result);
|
||||
setData((old) => ({ ...old, ...data }));
|
||||
setTemplates(
|
||||
Object.keys(data).reduce((acc, curr) => {
|
||||
Object.keys(data[curr]).forEach((c: keyof APIKindType) => {
|
||||
//prevent wrong overwriting of the component template by a group of the same type
|
||||
if (!data[curr][c].flow) acc[c] = data[curr][c];
|
||||
});
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// Set the types by reducing over the keys of the result data and updating the accumulator.
|
||||
setTypes(
|
||||
// Reverse the keys so the tool world does not overlap
|
||||
Object.keys(data)
|
||||
.reverse()
|
||||
.reduce((acc, curr) => {
|
||||
Object.keys(data[curr]).forEach((c: keyof APIKindType) => {
|
||||
acc[c] = curr;
|
||||
// Add the base classes to the accumulator as well.
|
||||
data[curr][c].base_classes?.forEach((b) => {
|
||||
acc[b] = curr;
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error has occurred while fetching types.");
|
||||
console.log(error);
|
||||
await getHealth().catch((e) => {
|
||||
setFetchError(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<typesContext.Provider
|
||||
value={{
|
||||
types,
|
||||
setTypes,
|
||||
setTemplates,
|
||||
templates,
|
||||
data,
|
||||
setData,
|
||||
getTypes,
|
||||
fetchError,
|
||||
setFetchError,
|
||||
setFilterEdge,
|
||||
getFilterEdge,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</typesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ import Chat from "../../../../components/chatComponent";
|
|||
import Loading from "../../../../components/ui/loading";
|
||||
import { FlowsContext } from "../../../../contexts/flowsContext";
|
||||
import { locationContext } from "../../../../contexts/locationContext";
|
||||
import { typesContext } from "../../../../contexts/typesContext";
|
||||
import { undoRedoContext } from "../../../../contexts/undoRedoContext";
|
||||
import useAlertStore from "../../../../stores/alertStore";
|
||||
import useFlowStore from "../../../../stores/flowStore";
|
||||
|
|
@ -42,6 +41,7 @@ import { cn, getRandomName, isWrappedWithClass } from "../../../../utils/utils";
|
|||
import ConnectionLineComponent from "../ConnectionLineComponent";
|
||||
import SelectionMenu from "../SelectionMenuComponent";
|
||||
import ExtraSidebar from "../extraSidebarComponent";
|
||||
import { useTypesStore } from "../../../../stores/typesStore";
|
||||
|
||||
const nodeTypes = {
|
||||
genericNode: GenericNode,
|
||||
|
|
@ -55,7 +55,9 @@ export default function Page({
|
|||
view?: boolean;
|
||||
}): JSX.Element {
|
||||
let { uploadFlow, saveFlow } = useContext(FlowsContext);
|
||||
const { types, templates, setFilterEdge } = useContext(typesContext);
|
||||
const types = useTypesStore((state) => state.types);
|
||||
const templates = useTypesStore((state) => state.templates);
|
||||
const setFilterEdge = useTypesStore((state) => state.setFilterEdge);
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [lastCopiedSelection, setLastCopiedSelection] = useState<{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import IconComponent from "../../../../components/genericIconComponent";
|
|||
import { Input } from "../../../../components/ui/input";
|
||||
import { Separator } from "../../../../components/ui/separator";
|
||||
import { FlowsContext } from "../../../../contexts/flowsContext";
|
||||
import { typesContext } from "../../../../contexts/typesContext";
|
||||
import ApiModal from "../../../../modals/ApiModal";
|
||||
import ExportModal from "../../../../modals/exportModal";
|
||||
import ShareModal from "../../../../modals/shareModal";
|
||||
|
|
@ -25,10 +24,13 @@ import {
|
|||
} from "../../../../utils/utils";
|
||||
import DisclosureComponent from "../DisclosureComponent";
|
||||
import SidebarDraggableComponent from "./sideBarDraggableComponent";
|
||||
import { useTypesStore } from "../../../../stores/typesStore";
|
||||
|
||||
export default function ExtraSidebar(): JSX.Element {
|
||||
const { data, templates, getFilterEdge, setFilterEdge } =
|
||||
useContext(typesContext);
|
||||
const data = useTypesStore((state) => state.data);
|
||||
const templates = useTypesStore((state) => state.templates);
|
||||
const getFilterEdge = useTypesStore((state) => state.getFilterEdge);
|
||||
const setFilterEdge = useTypesStore((state) => state.setFilterEdge);
|
||||
const { flows, tabId, uploadFlow, saveFlow } = useContext(FlowsContext);
|
||||
|
||||
const hasStore = useStoreStore((state) => state.hasStore);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ const useAlertStore = create<AlertStoreType>((set, get) => ({
|
|||
notificationCenter: false,
|
||||
notificationList: [],
|
||||
loading: true,
|
||||
fetchError: false,
|
||||
setFetchError: (newState: boolean) => {
|
||||
set({ fetchError: newState });
|
||||
},
|
||||
setErrorData: (newState: { title: string; list?: Array<string> }) => {
|
||||
if (newState.title && newState.title !== "") {
|
||||
set({
|
||||
|
|
|
|||
52
src/frontend/src/stores/typesStore.ts
Normal file
52
src/frontend/src/stores/typesStore.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { create } from "zustand";
|
||||
import { getAll, getHealth } from "../controllers/API";
|
||||
import { APIDataType, APIKindType } from "../types/api";
|
||||
import { TypesStoreType } from "../types/zustand/types";
|
||||
import useAlertStore from "./alertStore";
|
||||
import { templatesGenerator, typesGenerator } from "../utils/reactflowUtils";
|
||||
|
||||
export const useTypesStore = create<TypesStoreType>((set, get) => ({
|
||||
types: {},
|
||||
templates: {},
|
||||
data: {},
|
||||
getFilterEdge: [],
|
||||
getTypes: () => {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
getAll()
|
||||
.then((response) => {
|
||||
const data = response.data;
|
||||
useAlertStore.setState({ loading: false });
|
||||
set((old) => ({
|
||||
types: typesGenerator(data),
|
||||
data: { ...old.data, ...data },
|
||||
templates: templatesGenerator(data),
|
||||
}));
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("An error has occurred while fetching types.");
|
||||
console.log(error);
|
||||
getHealth().catch((e) => {
|
||||
useAlertStore.setState({
|
||||
fetchError: true,
|
||||
loading: false,
|
||||
});
|
||||
reject();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
setTypes: (newState: {}) => {
|
||||
set({ types: newState });
|
||||
},
|
||||
setTemplates: (newState: {}) => {
|
||||
set({ templates: newState });
|
||||
},
|
||||
setData: (change: APIDataType | ((old: APIDataType) => APIDataType)) => {
|
||||
let newChange = typeof change === "function" ? change(get().data) : change;
|
||||
set({ data: newChange });
|
||||
},
|
||||
setFilterEdge: (newState) => {
|
||||
set({ getFilterEdge: newState });
|
||||
},
|
||||
}));
|
||||
|
|
@ -2,8 +2,8 @@ import { Edge, Node, Viewport } from "reactflow";
|
|||
import { FlowType } from "../flow";
|
||||
//kind and class are just representative names to represent the actual structure of the object received by the API
|
||||
export type APIDataType = { [key: string]: APIKindType };
|
||||
export type APIObjectType = { kind: APIKindType; [key: string]: APIKindType };
|
||||
export type APIKindType = { class: APIClassType; [key: string]: APIClassType };
|
||||
export type APIObjectType = { [key: string]: APIKindType };
|
||||
export type APIKindType = { [key: string]: APIClassType };
|
||||
export type APITemplateType = {
|
||||
[key: string]: TemplateVariableType;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ReactFlowJsonObject, XYPosition } from "reactflow";
|
|||
import { APIClassType, APITemplateType, TemplateVariableType } from "../api";
|
||||
import { ChatMessageType } from "../chat";
|
||||
import { FlowStyleType, FlowType, NodeDataType, NodeType } from "../flow/index";
|
||||
import { typesContextType } from "../typesContext";
|
||||
import { sourceHandleType, targetHandleType } from "./../flow/index";
|
||||
import { TypesStoreType } from "../zustand/types";
|
||||
export type InputComponentType = {
|
||||
autoFocus?: boolean;
|
||||
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
|
|
@ -48,7 +48,6 @@ export type ParameterComponentType = {
|
|||
required?: boolean;
|
||||
name?: string;
|
||||
tooltipTitle: string | undefined;
|
||||
dataContext?: typesContextType;
|
||||
optionalHandle?: Array<String> | null;
|
||||
info?: string;
|
||||
proxy?: { field: string; id: string };
|
||||
|
|
|
|||
|
|
@ -1,24 +1,5 @@
|
|||
import { Edge, Node } from "reactflow";
|
||||
import { AlertItemType } from "../alerts";
|
||||
import { APIClassType, APIDataType } from "../api";
|
||||
|
||||
const types: { [char: string]: string } = {};
|
||||
const template: { [char: string]: APIClassType } = {};
|
||||
const data: { [char: string]: string } = {};
|
||||
|
||||
export type typesContextType = {
|
||||
types: typeof types;
|
||||
setTypes: (newState: {}) => void;
|
||||
templates: typeof template;
|
||||
setTemplates: (newState: {}) => void;
|
||||
data: APIDataType;
|
||||
setData: (newState: {}) => void;
|
||||
getTypes: () => void;
|
||||
fetchError: boolean;
|
||||
setFetchError: (newState: boolean) => void;
|
||||
setFilterEdge: (newState) => void;
|
||||
getFilterEdge: any[];
|
||||
};
|
||||
|
||||
export type alertContextType = {
|
||||
errorData: { title: string; list?: Array<string> };
|
||||
|
|
|
|||
|
|
@ -20,4 +20,6 @@ export type AlertStoreType = {
|
|||
removeFromNotificationList: (index: string) => void;
|
||||
loading: boolean;
|
||||
setLoading: (newState: boolean) => void;
|
||||
fetchError: boolean;
|
||||
setFetchError: (newState: boolean) => void;
|
||||
};
|
||||
|
|
|
|||
13
src/frontend/src/types/zustand/types/index.ts
Normal file
13
src/frontend/src/types/zustand/types/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { APIClassType, APIDataType } from "../../api";
|
||||
|
||||
export type TypesStoreType = {
|
||||
types: { [char: string]: string };
|
||||
setTypes: (newState: {}) => void;
|
||||
templates: { [char: string]: APIClassType };
|
||||
setTemplates: (newState: {}) => void;
|
||||
data: APIDataType;
|
||||
setData: (newState: {}) => void;
|
||||
getTypes: () => Promise<void>;
|
||||
setFilterEdge: (newState) => void;
|
||||
getFilterEdge: any[];
|
||||
}
|
||||
|
|
@ -12,7 +12,12 @@ import {
|
|||
LANGFLOW_SUPPORTED_TYPES,
|
||||
specialCharsRegex,
|
||||
} from "../constants/constants";
|
||||
import { APITemplateType, TemplateVariableType } from "../types/api";
|
||||
import {
|
||||
APIKindType,
|
||||
APIObjectType,
|
||||
APITemplateType,
|
||||
TemplateVariableType,
|
||||
} from "../types/api";
|
||||
import {
|
||||
FlowType,
|
||||
NodeDataType,
|
||||
|
|
@ -1138,3 +1143,28 @@ export function removeFileNameFromComponents(flow: FlowType) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function typesGenerator(data: APIObjectType) {
|
||||
return Object.keys(data)
|
||||
.reverse()
|
||||
.reduce((acc, curr) => {
|
||||
Object.keys(data[curr]).forEach((c: keyof APIKindType) => {
|
||||
acc[c] = curr;
|
||||
// Add the base classes to the accumulator as well.
|
||||
data[curr][c].base_classes?.forEach((b) => {
|
||||
acc[b] = curr;
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function templatesGenerator(data: APIObjectType) {
|
||||
return Object.keys(data).reduce((acc, curr) => {
|
||||
Object.keys(data[curr]).forEach((c: keyof APIKindType) => {
|
||||
//prevent wrong overwriting of the component template by a group of the same type
|
||||
if (!data[curr][c].flow) acc[c] = data[curr][c];
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue