From a99436e440f07832276523bdee1e91d0030b9afc Mon Sep 17 00:00:00 2001 From: Tudor Stanciu Date: Sun, 19 Mar 2023 00:07:56 +0200 Subject: [PATCH] Added @flare/tuitio-client-react in CDN app --- .env | 4 +- .env.production | 2 +- package-lock.json | 82 ++++++++++++-- package.json | 5 +- src/components/App.js | 15 +-- src/components/Header/Header.js | 12 ++- src/components/Header/styles.js | 90 ++++++++-------- src/components/Widget/Widget.js | 2 +- src/components/Widget/WidgetView.js | 4 +- src/contexts/UserContext.js | 100 ------------------ .../edit/components/ResourceComponent.js | 2 +- .../edit/components/ResourceContainer.js | 2 - src/hooks/useResourceSecurity.js | 8 +- src/index.js | 6 +- src/pages/login/Login.js | 61 +++++------ src/themes/index.js | 21 ++-- src/utils/axios.js | 9 +- src/utils/identity.js | 52 --------- 18 files changed, 187 insertions(+), 290 deletions(-) delete mode 100644 src/contexts/UserContext.js delete mode 100644 src/utils/identity.js diff --git a/.env b/.env index 611288d..dee40f7 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ -REACT_APP_IDENTITY_AUTHENTICATION_URL=https://lab.code-rove.com/identity-server-api/identity/authenticate?UserName={username}&Password={password} +REACT_APP_TUITIO_URL=https://lab.code-rove.com/tuitio REACT_APP_ENABLE_TEMPLATE_CONTENT=True -# REACT_APP_CDN_URL=http://localhost:5050 +#REACT_APP_CDN_URL=http://localhost:5050 REACT_APP_CDN_URL=https://lab.code-rove.com/cdn #PUBLIC_URL=/cdn-admin/ \ No newline at end of file diff --git a/.env.production b/.env.production index 9dbd55f..571dbb2 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ PUBLIC_URL=/cdn-admin/ -REACT_APP_IDENTITY_AUTHENTICATION_URL=https://lab.code-rove.com/identity-server-api/identity/authenticate?UserName={username}&Password={password} +REACT_APP_TUITIO_URL=https://lab.code-rove.com/tuitio REACT_APP_ENABLE_TEMPLATE_CONTENT=False REACT_APP_CDN_URL=https://lab.code-rove.com/cdn \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28c5294..724f901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1325,9 +1325,53 @@ } }, "@flare/js-utils": { - "version": "1.0.2", - "resolved": "https://lab.code-rove.com/public-node-registry/@flare%2fjs-utils/-/js-utils-1.0.2.tgz", - "integrity": "sha512-49KjpcRUD6fXPC60bmCsPa8frU6wlQyI5RsjMD70zsuvk8BZFdopsETsjsCembbmzTScIhfMACU4RnimOEwg9Q==" + "version": "1.0.3", + "resolved": "https://lab.code-rove.com/public-node-registry/@flare/js-utils/-/js-utils-1.0.3.tgz", + "integrity": "sha512-VgXQHoQEVZ/71B6YQHQP8/Yd/w1smGD+kCCiNvJKZ1xMD3nkN9mjoHxIqbOJMZ2q5PZlV6gXYT7eVol8Wm+D0A==" + }, + "@flare/tuitio-client": { + "version": "1.1.0", + "resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client/-/tuitio-client-1.1.0.tgz", + "integrity": "sha512-m2cYFaIdHx6eGqSo1V9Rx5sy8Zz5Y/y02OCTwuA++QceJLelUeUID+ymEts54nkG6nFxsGRBaNhpcC5lwsPMng==", + "requires": { + "@flare/js-utils": "^1.0.3", + "axios": "^1.3.2" + }, + "dependencies": { + "axios": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz", + "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, + "@flare/tuitio-client-react": { + "version": "1.1.1", + "resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client-react/-/tuitio-client-react-1.1.1.tgz", + "integrity": "sha512-CTxc1Ra0b3RcMuHakIw5B+d/n/42N8s64avXPAWKmWfBkuOIdPJ3P9s2kpG+CV2MIBlUCPSTbfOi+Ve1pf/cig==", + "requires": { + "@flare/tuitio-client": "^1.1.0" + } }, "@gar/promisify": { "version": "1.1.3", @@ -3391,11 +3435,30 @@ "integrity": "sha512-lCZN5XRuOnpG4bpMq8v0khrWtUOn+i8lZSb6wHZH56ZfbIEv6XwJV84AAueh9/zi7qPVJ/E4yz6fmsiyOmXR4w==" }, "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz", + "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", "requires": { - "follow-redirects": "1.5.10" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } } }, "axobject-query": { @@ -12806,6 +12869,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/package.json b/package.json index 42aa272..fc7e49f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "node-fetch": "^2.6.1" }, "dependencies": { - "@flare/js-utils": "^1.0.2", + "@flare/js-utils": "^1.0.3", + "@flare/tuitio-client-react": "^1.1.1", "@material-ui/core": "^4.11.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", @@ -28,7 +29,7 @@ "@mdi/react": "^1.4.0", "@babel/eslint-parser": "^7.16.5", "apexcharts": "^3.24.0", - "axios": "^0.19.2", + "axios": "^1.3.4", "classnames": "^2.2.6", "font-awesome": "^4.7.0", "i18next": "^19.4.4", diff --git a/src/components/App.js b/src/components/App.js index c9242c4..6afcf0c 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -8,21 +8,10 @@ import Layout from "./Layout/Layout"; import Error from "../pages/error"; import Login from "../pages/login"; import ServerNotAvailable from "../features/server/availability/components/ServerNotAvailable"; - -// context -import { useUserState } from "../contexts/UserContext"; -import { useToast } from "../contexts/ToastContext"; -import { useTranslation } from "react-i18next"; +import { useTuitioToken } from "@flare/tuitio-client-react"; export default function App() { - const { authenticated, messageCode } = useUserState(); - const { t } = useTranslation(); - const { notify } = useToast(); - - useEffect(() => { - if (!messageCode) return; - notify(t(messageCode), "error"); - }, [messageCode, t, notify]); + const { valid: authenticated } = useTuitioToken(); return ( diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index d6fedbc..be1d9e6 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -33,7 +33,8 @@ import { useLayoutDispatch, toggleSidebar } from "../../contexts/LayoutContext"; -import { useUserDispatch, signOut } from "../../contexts/UserContext"; +import { useTuitioClient } from "@flare/tuitio-client-react"; +import { useToast } from "../../hooks"; const messages = [ { @@ -94,7 +95,6 @@ export default function Header(props) { // global var layoutState = useLayoutState(); var layoutDispatch = useLayoutDispatch(); - var userDispatch = useUserDispatch(); // local var [mailMenu, setMailMenu] = useState(null); @@ -104,6 +104,12 @@ export default function Header(props) { var [profileMenu, setProfileMenu] = useState(null); var [isSearchOpen, setSearchOpen] = useState(false); + const { error } = useToast(); + const { logout } = useTuitioClient({ + onLogoutFailed: (errorMessage) => error(errorMessage), + onLogoutError: (err) => error(err.message) + }); + return ( @@ -320,7 +326,7 @@ export default function Header(props) { signOut(userDispatch, props.history)} + onClick={logout} > Sign Out diff --git a/src/components/Header/styles.js b/src/components/Header/styles.js index a375abb..751ddee 100644 --- a/src/components/Header/styles.js +++ b/src/components/Header/styles.js @@ -1,7 +1,7 @@ import { makeStyles } from "@material-ui/styles"; -import { fade } from "@material-ui/core/styles/colorManipulator"; +import { alpha } from "@material-ui/core/styles"; -export default makeStyles(theme => ({ +export default makeStyles((theme) => ({ logotype: { color: "white", marginLeft: theme.spacing(2.5), @@ -10,45 +10,45 @@ export default makeStyles(theme => ({ fontSize: 18, whiteSpace: "nowrap", [theme.breakpoints.down("xs")]: { - display: "none", - }, + display: "none" + } }, appBar: { width: "100vw", zIndex: theme.zIndex.drawer + 1, transition: theme.transitions.create(["margin"], { easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen, - }), + duration: theme.transitions.duration.leavingScreen + }) }, toolbar: { paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), + paddingRight: theme.spacing(2) }, hide: { - display: "none", + display: "none" }, grow: { - flexGrow: 1, + flexGrow: 1 }, search: { position: "relative", borderRadius: 25, paddingLeft: theme.spacing(2.5), width: 36, - backgroundColor: fade(theme.palette.common.black, 0), + backgroundColor: alpha(theme.palette.common.black, 0), transition: theme.transitions.create(["background-color", "width"]), "&:hover": { cursor: "pointer", - backgroundColor: fade(theme.palette.common.black, 0.08), - }, + backgroundColor: alpha(theme.palette.common.black, 0.08) + } }, searchFocused: { - backgroundColor: fade(theme.palette.common.black, 0.08), + backgroundColor: alpha(theme.palette.common.black, 0.08), width: "100%", [theme.breakpoints.up("md")]: { - width: 250, - }, + width: 250 + } }, searchIcon: { width: 36, @@ -60,115 +60,115 @@ export default makeStyles(theme => ({ justifyContent: "center", transition: theme.transitions.create("right"), "&:hover": { - cursor: "pointer", - }, + cursor: "pointer" + } }, searchIconOpened: { - right: theme.spacing(1.25), + right: theme.spacing(1.25) }, inputRoot: { color: "inherit", - width: "100%", + width: "100%" }, inputInput: { height: 36, padding: 0, paddingRight: 36 + theme.spacing(1.25), - width: "100%", + width: "100%" }, messageContent: { display: "flex", - flexDirection: "column", + flexDirection: "column" }, headerMenu: { - marginTop: theme.spacing(7), + marginTop: theme.spacing(7) }, headerMenuList: { display: "flex", - flexDirection: "column", + flexDirection: "column" }, headerMenuItem: { "&:hover, &:focus": { - backgroundColor: theme.palette.background.light, + backgroundColor: theme.palette.background.light // color: "white", - }, + } }, headerMenuButton: { marginLeft: theme.spacing(2), - padding: theme.spacing(0.5), + padding: theme.spacing(0.5) }, headerMenuButtonSandwich: { marginLeft: 9, [theme.breakpoints.down("sm")]: { marginLeft: 0 }, - padding: theme.spacing(0.5), + padding: theme.spacing(0.5) }, headerMenuButtonCollapse: { - marginRight: theme.spacing(2), + marginRight: theme.spacing(2) }, headerIcon: { fontSize: 28, - color: "rgba(255, 255, 255, 0.35)", + color: "rgba(255, 255, 255, 0.35)" }, headerIconCollapse: { - color: "white", + color: "white" }, profileMenu: { - minWidth: 265, + minWidth: 265 }, profileMenuUser: { display: "flex", flexDirection: "column", - padding: theme.spacing(2), + padding: theme.spacing(2) }, profileMenuItem: { - color: theme.palette.text.hint, + color: theme.palette.text.hint }, profileMenuIcon: { marginRight: theme.spacing(2), color: theme.palette.text.hint, - '&:hover': { - color: theme.palette.primary.main, + "&:hover": { + color: theme.palette.primary.main } }, profileMenuLink: { fontSize: 16, textDecoration: "none", "&:hover": { - cursor: "pointer", - }, + cursor: "pointer" + } }, messageNotification: { height: "auto", display: "flex", alignItems: "center", "&:hover, &:focus": { - backgroundColor: theme.palette.background.light, - }, + backgroundColor: theme.palette.background.light + } }, messageNotificationSide: { display: "flex", flexDirection: "column", alignItems: "center", - marginRight: theme.spacing(2), + marginRight: theme.spacing(2) }, messageNotificationBodySide: { alignItems: "flex-start", - marginRight: 0, + marginRight: 0 }, sendMessageButton: { margin: theme.spacing(4), marginTop: theme.spacing(2), marginBottom: theme.spacing(2), - textTransform: "none", + textTransform: "none" }, sendButtonIcon: { - marginLeft: theme.spacing(2), + marginLeft: theme.spacing(2) }, purchaseBtn: { - [theme.breakpoints.down('sm')]: { - display: 'none' + [theme.breakpoints.down("sm")]: { + display: "none" }, marginRight: theme.spacing(3) } diff --git a/src/components/Widget/Widget.js b/src/components/Widget/Widget.js index bd3b110..b1666c1 100644 --- a/src/components/Widget/Widget.js +++ b/src/components/Widget/Widget.js @@ -61,7 +61,7 @@ export default function Widget({ aria-owns="widget-menu" aria-haspopup="true" onClick={() => setMoreMenuOpen(true)} - buttonRef={setMoreButtonRef} + ref={setMoreButtonRef} > diff --git a/src/components/Widget/WidgetView.js b/src/components/Widget/WidgetView.js index e7f1e21..b110e6f 100644 --- a/src/components/Widget/WidgetView.js +++ b/src/components/Widget/WidgetView.js @@ -37,7 +37,7 @@ const Widget = ({ aria-owns="widget-menu" aria-haspopup="true" onClick={() => props.setMoreMenuOpen(true)} - buttonRef={props.setMoreButtonRef} + ref={props.setMoreButtonRef} > @@ -77,7 +77,7 @@ const Widget = ({ ); -const styles = theme => ({ +const styles = (theme) => ({ widgetWrapper: { display: "flex", minHeight: "100%" diff --git a/src/contexts/UserContext.js b/src/contexts/UserContext.js deleted file mode 100644 index 47940f1..0000000 --- a/src/contexts/UserContext.js +++ /dev/null @@ -1,100 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { authenticate, invalidate, validateToken } from "../utils/identity"; - -var UserStateContext = React.createContext(); -var UserDispatchContext = React.createContext(); - -function userReducer(state, action) { - switch (action.type) { - case "LOGIN_SUCCESS": - return { ...state, ...action.payload, authenticated: true }; - case "SIGN_OUT_SUCCESS": - return { ...state, authenticated: false }; - case "LOGIN_FAILURE": - return { - ...state, - authenticated: false, - messageCode: action.payload?.messageCode - }; - default: { - throw new Error(`Unhandled action type: ${action.type}`); - } - } -} - -function UserProvider({ children }) { - const { valid, token } = validateToken(); - var [state, dispatch] = React.useReducer(userReducer, { - authenticated: valid === true, - token - }); - - return ( - - - {children} - - - ); -} - -UserProvider.propTypes = { - children: PropTypes.node -}; - -function useUserState() { - var context = React.useContext(UserStateContext); - if (context === undefined) { - throw new Error("useUserState must be used within a UserProvider"); - } - return context; -} - -function useUserDispatch() { - var context = React.useContext(UserDispatchContext); - if (context === undefined) { - throw new Error("useUserDispatch must be used within a UserProvider"); - } - return context; -} - -function setLoginFailed(dispatch, setError, setIsLoading, messageCode) { - dispatch({ type: "LOGIN_FAILURE", payload: { messageCode } }); - setError(true); - setIsLoading(false); -} - -function loginUser(dispatch, login, password, history, setIsLoading, setError) { - setError(false); - setIsLoading(true); - - if (!!login && !!password) { - authenticate(login, password).then((response) => { - if (response.status === "SUCCESS") { - setError(null); - setIsLoading(false); - dispatch({ type: "LOGIN_SUCCESS", payload: { token: response.token } }); - } else if (response.status === "BAD_CREDENTIALS") { - setLoginFailed( - dispatch, - setError, - setIsLoading, - "Login.IncorrectCredentials" - ); - } - }); - - history.push("/dashboard"); - } else { - setLoginFailed(dispatch, setError, setIsLoading, "Login.EmptyCredentials"); - } -} - -function signOut(dispatch, history) { - invalidate(); - dispatch({ type: "SIGN_OUT_SUCCESS" }); - history.push("/login"); -} - -export { UserProvider, useUserState, useUserDispatch, loginUser, signOut }; diff --git a/src/features/resources/edit/components/ResourceComponent.js b/src/features/resources/edit/components/ResourceComponent.js index 876eb8f..dd77b33 100644 --- a/src/features/resources/edit/components/ResourceComponent.js +++ b/src/features/resources/edit/components/ResourceComponent.js @@ -136,7 +136,7 @@ const ResourceComponent = ({ { const params = useParams(); const { getResource } = useResourcesApi(); const { getMimeTypes, getResourceCategories } = useDictionariesApi(); - const { success } = useToast(); const isNew = useMemo(() => params.id === "new", [params.id]); diff --git a/src/hooks/useResourceSecurity.js b/src/hooks/useResourceSecurity.js index 369dc03..641ae63 100644 --- a/src/hooks/useResourceSecurity.js +++ b/src/hooks/useResourceSecurity.js @@ -1,15 +1,15 @@ -import { useUserState } from "../contexts/UserContext"; +import { useTuitioToken } from "@flare/tuitio-client-react"; import { useCallback } from "react"; const useResourceSecurity = () => { - const { token } = useUserState(); + const { token } = useTuitioToken(); const secureUrl = useCallback( (url) => { const separator = url.includes("?") ? "&" : "?"; - const securedUrl = `${url}${separator}token=${token.raw}`; + const securedUrl = `${url}${separator}token=${token}`; return securedUrl; }, - [token.raw] + [token] ); return { secureUrl }; diff --git a/src/index.js b/src/index.js index a6e4b72..4e4d46b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,18 @@ import React, { Suspense } from "react"; import ReactDOM from "react-dom"; +import { TuitioProvider } from "@flare/tuitio-client-react"; import { ThemeProvider } from "@material-ui/styles"; import { CssBaseline } from "@material-ui/core"; import Themes from "./themes"; import App from "./components/App"; import * as serviceWorker from "./serviceWorker"; import { LayoutProvider } from "./contexts/LayoutContext"; -import { UserProvider } from "./contexts/UserContext"; import { ToastProvider } from "./contexts/ToastContext"; import "./utils/i18n"; ReactDOM.render( - + Loading...}> @@ -21,7 +21,7 @@ ReactDOM.render( - + , document.getElementById("root") ); diff --git a/src/pages/login/Login.js b/src/pages/login/Login.js index 35e26d0..1541432 100644 --- a/src/pages/login/Login.js +++ b/src/pages/login/Login.js @@ -18,23 +18,32 @@ import useStyles from "./styles"; // logo import logo from "./logo.svg"; import google from "../../images/google.svg"; +import { useToast } from "../../hooks"; +import { useTuitioClient } from "@flare/tuitio-client-react"; -// context -import { useUserDispatch, loginUser } from "../../contexts/UserContext"; +function Login() { + const classes = useStyles(); + const [activeTabId, setActiveTabId] = useState(0); + const [nameValue, setNameValue] = useState(""); + const [loginValue, setLoginValue] = useState(""); + const [passwordValue, setPasswordValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errorEncountered, setErrorEncountered] = useState(false); -function Login(props) { - var classes = useStyles(); + const { error } = useToast(); + const { login } = useTuitioClient({ + onLoginSuccess: () => setIsLoading(false), + onLoginFailed: () => error(t("Login.IncorrectCredentials")), + onLoginError: (err) => { + setErrorEncountered(true); + error(err.message); + } + }); - // global - var userDispatch = useUserDispatch(); - - // local - var [isLoading, setIsLoading] = useState(false); - var [error, setError] = useState(null); - var [activeTabId, setActiveTabId] = useState(0); - var [nameValue, setNameValue] = useState(""); - var [loginValue, setLoginValue] = useState(""); - var [passwordValue, setPasswordValue] = useState(""); + const handleLogin = () => { + setIsLoading(true); + login(loginValue, passwordValue); + }; return ( @@ -68,7 +77,7 @@ function Login(props) { or
- + Something is wrong with your login or password :( @@ -111,16 +120,7 @@ function Login(props) { disabled={ loginValue.length === 0 || passwordValue.length === 0 } - onClick={() => - loginUser( - userDispatch, - loginValue, - passwordValue, - props.history, - setIsLoading, - setError - ) - } + onClick={handleLogin} variant="contained" color="primary" size="large" @@ -201,16 +201,7 @@ function Login(props) { ) : (