Skip to content

Commit b91f166

Browse files
committed
FWF-5972 : [Feature] Added user settings profile
1 parent 14944ba commit b91f166

File tree

4 files changed

+272
-48
lines changed

4 files changed

+272
-48
lines changed

forms-flow-components/src/components/CustomComponents/CustomInfo.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ interface CustomInfoProps {
99
variant?: InfoVariant;
1010
className?: string;
1111
dataTestId?: string;
12+
/** Optional icon override (replaces the default InfoIcon). */
13+
icon?: React.ReactNode;
1214
}
1315

1416
/**
@@ -22,15 +24,17 @@ export const CustomInfo: FC<CustomInfoProps> = ({
2224
content,
2325
variant = "primary",
2426
className,
25-
dataTestId
27+
dataTestId,
28+
icon
2629
}) => {
2730
const { t } = useTranslation();
2831

29-
// Replace `\n` with <br /> tags and use the line itself as a key
30-
const formattedContent = content.split("\n").map((line) => (
31-
<React.Fragment key={line.trim().replace(/\s+/g, "-")}>
32+
// Replace `\n` with <br /> tags (no trailing <br />)
33+
const contentLines = content.split("\n");
34+
const formattedContent = contentLines.map((line, idx) => (
35+
<React.Fragment key={`${idx}-${line.trim().replace(/\s+/g, "-")}`}>
3236
{t(line)}
33-
<br />
37+
{idx < contentLines.length - 1 ? <br /> : null}
3438
</React.Fragment>
3539
));
3640

@@ -43,7 +47,7 @@ export const CustomInfo: FC<CustomInfoProps> = ({
4347
return (
4448
<div className={panelClassName} data-testid={dataTestId}>
4549
<div className="info-icon">
46-
<InfoIcon variant={variant} />
50+
{icon ?? <InfoIcon variant={variant} />}
4751
</div>
4852
<div className="info-content">{formattedContent}</div>
4953
</div>

forms-flow-components/src/components/SvgIcons/index.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,17 @@ export const BackToPrevIcon = ({ color = baseColor, onClick, ...props }) => (
398398
</svg>
399399
);
400400

401-
export const ApplicationLogo = ({ color = baseColor, ...props }) => (
401+
export const ApplicationLogo = ({
402+
color = "#1B34FB",
403+
width = 16,
404+
height = 21,
405+
...props
406+
}: {
407+
color?: string;
408+
width?: number | string;
409+
height?: number | string;
410+
[key: string]: any;
411+
}) => (
402412
// <svg
403413
// xmlns="http://www.w3.org/2000/svg"
404414
// width="144"
@@ -458,8 +468,15 @@ export const ApplicationLogo = ({ color = baseColor, ...props }) => (
458468
// fill="black"
459469
// />
460470
// </svg>
461-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="21" viewBox="0 0 16 21" fill="none">
462-
<path fillRule="evenodd" clipRule="evenodd" d="M7.30992 0.241904C6.98738 -0.0806347 6.46444 -0.0806347 6.1419 0.241904C5.81937 0.564443 5.81937 1.08738 6.1419 1.40992L7.1741 2.44212C3.14386 2.85546 0 6.26059 0 10.4C0 13.2954 1.53818 15.8316 3.84197 17.2359C4.27304 17.4987 4.8126 17.2432 4.94327 16.7555C5.04336 16.382 4.86452 15.994 4.53938 15.7846C2.77105 14.6458 1.6 12.6596 1.6 10.4C1.6 7.45211 3.59305 4.96973 6.30516 4.22682L6.1419 4.39008C5.81937 4.71262 5.81937 5.23556 6.1419 5.5581C6.46444 5.88064 6.98738 5.88064 7.30992 5.5581L9.38401 3.48401C9.70655 3.16147 9.70655 2.63853 9.38401 2.31599L7.30992 0.241904ZM8.21601 19.9581C8.53855 20.2806 9.06149 20.2806 9.38402 19.9581C9.70656 19.6356 9.70656 19.1126 9.38402 18.7901L8.93937 18.3454C12.915 17.8804 16 14.5005 16 10.4C16 7.41613 14.3664 4.8138 11.9448 3.4386C11.5152 3.19463 10.9928 3.4512 10.865 3.92841C10.7625 4.31082 10.9532 4.70681 11.2925 4.91077C13.1544 6.02993 14.4 8.0695 14.4 10.4C14.4 13.798 11.7518 16.5775 8.40666 16.7873L9.38402 15.8099C9.70656 15.4874 9.70656 14.9645 9.38402 14.6419C9.06149 14.3194 8.53855 14.3194 8.21601 14.6419L6.14192 16.716C5.81938 17.0385 5.81938 17.5615 6.14192 17.884L8.21601 19.9581ZM11.5259 7.53431C11.2135 7.22189 10.7065 7.22189 10.394 7.53431L7.1997 10.7286L5.80517 9.33412C5.49276 9.02201 4.98664 9.02189 4.67431 9.33412C4.36189 9.64654 4.36189 10.1535 4.67431 10.466L6.6411 12.4318C6.95352 12.7442 7.45954 12.7442 7.77196 12.4318C7.80682 12.3969 7.83766 12.3596 7.86474 12.3204C7.88561 12.3034 7.90582 12.2852 7.92528 12.2658L11.5259 8.66517C11.8379 8.35277 11.838 7.84664 11.5259 7.53431Z" fill="#253DF4"/>
471+
<svg
472+
xmlns="http://www.w3.org/2000/svg"
473+
width={width}
474+
height={height}
475+
viewBox="0 0 16 21"
476+
fill="none"
477+
{...props}
478+
>
479+
<path fillRule="evenodd" clipRule="evenodd" d="M7.30992 0.241904C6.98738 -0.0806347 6.46444 -0.0806347 6.1419 0.241904C5.81937 0.564443 5.81937 1.08738 6.1419 1.40992L7.1741 2.44212C3.14386 2.85546 0 6.26059 0 10.4C0 13.2954 1.53818 15.8316 3.84197 17.2359C4.27304 17.4987 4.8126 17.2432 4.94327 16.7555C5.04336 16.382 4.86452 15.994 4.53938 15.7846C2.77105 14.6458 1.6 12.6596 1.6 10.4C1.6 7.45211 3.59305 4.96973 6.30516 4.22682L6.1419 4.39008C5.81937 4.71262 5.81937 5.23556 6.1419 5.5581C6.46444 5.88064 6.98738 5.88064 7.30992 5.5581L9.38401 3.48401C9.70655 3.16147 9.70655 2.63853 9.38401 2.31599L7.30992 0.241904ZM8.21601 19.9581C8.53855 20.2806 9.06149 20.2806 9.38402 19.9581C9.70656 19.6356 9.70656 19.1126 9.38402 18.7901L8.93937 18.3454C12.915 17.8804 16 14.5005 16 10.4C16 7.41613 14.3664 4.8138 11.9448 3.4386C11.5152 3.19463 10.9928 3.4512 10.865 3.92841C10.7625 4.31082 10.9532 4.70681 11.2925 4.91077C13.1544 6.02993 14.4 8.0695 14.4 10.4C14.4 13.798 11.7518 16.5775 8.40666 16.7873L9.38402 15.8099C9.70656 15.4874 9.70656 14.9645 9.38402 14.6419C9.06149 14.3194 8.53855 14.3194 8.21601 14.6419L6.14192 16.716C5.81938 17.0385 5.81938 17.5615 6.14192 17.884L8.21601 19.9581ZM11.5259 7.53431C11.2135 7.22189 10.7065 7.22189 10.394 7.53431L7.1997 10.7286L5.80517 9.33412C5.49276 9.02201 4.98664 9.02189 4.67431 9.33412C4.36189 9.64654 4.36189 10.1535 4.67431 10.466L6.6411 12.4318C6.95352 12.7442 7.45954 12.7442 7.77196 12.4318C7.80682 12.3969 7.83766 12.3596 7.86474 12.3204C7.88561 12.3034 7.90582 12.2852 7.92528 12.2658L11.5259 8.66517C11.8379 8.35277 11.838 7.84664 11.5259 7.53431Z" fill={color}/>
463480
</svg>
464481
);
465482
export const MenuToggleIcon = () => (

forms-flow-nav/src/sidenav/ProfileSettingsModal.jsx

Lines changed: 197 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,51 @@
11
import React, { useState, useEffect } from 'react';
22
import PropTypes from 'prop-types';
33
import Modal from 'react-bootstrap/Modal';
4-
import { CloseIcon, V8CustomButton, CustomInfo, SelectDropdown } from "@formsflow/components";
4+
import { Tabs, Tab } from 'react-bootstrap';
5+
import { CloseIcon, V8CustomButton, CustomInfo, SelectDropdown, CustomTextInput, ApplicationLogo } from "@formsflow/components";
56
import { fetchSelectLanguages, updateUserlang } from '../services/language';
67
import { useTranslation } from "react-i18next";
78
import i18n from '../resourceBundles/i18n';
89
import { StorageService } from "@formsflow/service";
9-
import { LANGUAGE, MULTITENANCY_ENABLED, USER_LANGUAGE_LIST } from '../constants/constants';
10+
import { KEYCLOAK_AUTH_URL, KEYCLOAK_REALM, LANGUAGE, MULTITENANCY_ENABLED, USER_LANGUAGE_LIST } from '../constants/constants';
1011

1112
export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
1213
const [selectLanguages, setSelectLanguages] = useState([]);
1314
const prevSelectedLang = localStorage.getItem('i18nextLng');
1415
const [selectedLang, setSelectedLang] = useState(prevSelectedLang || LANGUAGE );
1516
const [daysDifference, setDaysDifference] = useState(null);
17+
const [activeTab, setActiveTab] = useState("Profile");
18+
const [profileFields, setProfileFields] = useState({
19+
firstName: "",
20+
lastName: "",
21+
email: "",
22+
username: "",
23+
});
24+
const [initialProfileFields, setInitialProfileFields] = useState(null);
1625
const { t } = useTranslation();
1726

27+
useEffect(() => {
28+
if (!show) return;
29+
try {
30+
const userDetail = JSON.parse(StorageService.get(StorageService.User.USER_DETAILS)) || {};
31+
const fullName = userDetail?.name || "";
32+
const [firstFromName = "", ...rest] = String(fullName).trim().split(/\s+/);
33+
const lastFromName = rest.join(" ");
34+
35+
const nextFields = {
36+
firstName: userDetail?.given_name || firstFromName || "",
37+
lastName: userDetail?.family_name || lastFromName || "",
38+
email: userDetail?.email || "",
39+
username: userDetail?.preferred_username || userDetail?.username || "",
40+
};
41+
setProfileFields(nextFields);
42+
setInitialProfileFields(nextFields);
43+
} catch (e) {
44+
setProfileFields({ firstName: "", lastName: "", email: "", username: "" });
45+
setInitialProfileFields({ firstName: "", lastName: "", email: "", username: "" });
46+
}
47+
}, [show]);
48+
1849
useEffect(() => {
1950

2051
fetchSelectLanguages((languages) => {
@@ -56,59 +87,192 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
5687
};
5788

5889
const handleConfirmProfile = () => {
59-
updateUserlang(selectedLang);
60-
i18n.changeLanguage(selectedLang);
61-
if (tenant?.tenantData?.details) {
62-
tenant.tenantData.details.locale = selectedLang;
63-
}
64-
if (selectedLang) {
65-
publish("ES_CHANGE_LANGUAGE", selectedLang);
90+
// Keep a copy for later integration; for now just save it locally and close the modal.
91+
const firstName = (profileFields.firstName || "").trim();
92+
const lastName = (profileFields.lastName || "").trim();
93+
94+
const userCopy = {
95+
user: {
96+
firstName,
97+
lastName,
98+
userName: (profileFields.username || "").trim(),
99+
email: (profileFields.email || "").trim(),
100+
attributes: {
101+
locale: [selectedLang],
102+
},
103+
},
104+
};
105+
106+
try {
107+
StorageService.save("PROFILE_SETTINGS_USER_COPY", JSON.stringify(userCopy));
108+
} catch (e) {
109+
// ignore
66110
}
67111

68-
onClose();
112+
onClose();
69113
};
70114

71115

72116
const isSaveDisabled = selectedLang === prevSelectedLang;
117+
const isProfileChanged =
118+
!!initialProfileFields &&
119+
(profileFields.firstName !== initialProfileFields.firstName ||
120+
profileFields.lastName !== initialProfileFields.lastName ||
121+
profileFields.email !== initialProfileFields.email ||
122+
profileFields.username !== initialProfileFields.username);
123+
const isAnythingChanged = isProfileChanged || !isSaveDisabled;
73124

74125
const selectedLangLabel = selectLanguages.find(lang => lang.name === selectedLang)?.value || selectedLang;
75126

127+
const tabs = [
128+
{ key: "Profile", label: t("Profile") },
129+
{ key: "Permissions", label: t("Permissions") },
130+
];
131+
132+
const resetPasswordUrl =
133+
KEYCLOAK_AUTH_URL && KEYCLOAK_REALM
134+
? `${KEYCLOAK_AUTH_URL}/realms/${KEYCLOAK_REALM}/account`
135+
: null;
136+
76137
// Get tenantId from tenant prop or StorageService
77138
const tenantId = tenant?.tenantId || StorageService.get("tenantKey");
78139

79140
return (
80141
<Modal
81142
show={show}
82143
onHide={onClose}
83-
size="sm"
144+
size="lg"
145+
dialogClassName="profile-settings-modal"
84146
data-testid="profile-settings-modal"
85147
aria-labelledby={t("profile settings modal title")}
86148
aria-describedby="profile-settings-modal"
87149
backdrop="static"
88150
>
89-
<Modal.Header className="justify-content-between">
90-
<Modal.Title id="profile-modal-title">
91-
<p>{t("Settings")}</p>
92-
</Modal.Title>
93-
<div className="icon-close" onClick={onClose}>
94-
<CloseIcon />
151+
<Modal.Header>
152+
<div className="modal-header-content">
153+
<div className="modal-title pb-0">
154+
<p>{t("Personal Settings")}</p>
155+
<CloseIcon color="var(--gray-darkest)" onClick={onClose}/>
156+
</div>
157+
<div className="modal-subtitle pb-0">
158+
<div className='secondary-controls'>
159+
<div className='pill-tabs-container'>
160+
<Tabs
161+
activeKey={activeTab}
162+
onSelect={(key) => key && setActiveTab(key)}
163+
id="profile-settings-tabs"
164+
data-testid="profile-settings-tabs"
165+
className="pill-tabs"
166+
>
167+
{tabs.map((tab) => (
168+
<Tab
169+
key={tab.key}
170+
eventKey={tab.key}
171+
title={
172+
<span data-testid={`profile-settings-${tab.key}-tab`}>
173+
{tab.label}
174+
</span>
175+
}
176+
>
177+
{/* Empty content; this is navigation. Body renders based on activeTab. */}
178+
</Tab>
179+
))}
180+
</Tabs>
181+
</div>
182+
</div>
183+
184+
</div>
95185
</div>
186+
96187
</Modal.Header>
97188

98189
<Modal.Body>
99-
<SelectDropdown
100-
options={selectLanguages.map((lang) => ({
101-
label: lang.value,
102-
value: lang.name,
103-
}))}
104-
defaultValue={selectedLangLabel}
105-
dataTestId="settings-language-dropdown"
106-
ariaLabel={t("Language Dropdown")}
107-
value={selectedLangLabel}
108-
variant="primary"
109-
className="mb-3 w-100"
110-
onChange={handleLanguageChange}
111-
/>
190+
{activeTab === "Profile" ? (
191+
<>
192+
<div className="profile-settings-details-box p-3 mb-3 border rounded">
193+
<div className="row g-3">
194+
<div className="col-12 col-md-6">
195+
<div className="input-label-text">{t("First Name")}</div>
196+
<CustomTextInput
197+
value={profileFields.firstName}
198+
setValue={(v) => setProfileFields((p) => ({ ...p, firstName: v }))}
199+
placeholder="First Name"
200+
dataTestId="profile-first-name"
201+
ariaLabel={t("First Name")}
202+
/>
203+
</div>
204+
<div className="col-12 col-md-6">
205+
<div className="input-label-text">{t("Last Name")}</div>
206+
<CustomTextInput
207+
value={profileFields.lastName}
208+
setValue={(v) => setProfileFields((p) => ({ ...p, lastName: v }))}
209+
placeholder="Last Name"
210+
dataTestId="profile-last-name"
211+
ariaLabel={t("Last Name")}
212+
/>
213+
</div>
214+
<div className="col-12 col-md-6">
215+
<div className="input-label-text">{t("Email")}</div>
216+
<CustomTextInput
217+
value={profileFields.email}
218+
setValue={(v) => setProfileFields((p) => ({ ...p, email: v }))}
219+
placeholder="Email"
220+
dataTestId="profile-email"
221+
ariaLabel={t("Email")}
222+
/>
223+
</div>
224+
<div className="col-12 col-md-6">
225+
<div className="input-label-text">{t("Username")}</div>
226+
<CustomTextInput
227+
value={profileFields.username}
228+
setValue={(v) => setProfileFields((p) => ({ ...p, username: v }))}
229+
placeholder="Username"
230+
dataTestId="profile-username"
231+
ariaLabel={t("Username")}
232+
/>
233+
</div>
234+
<div className="col-12">
235+
<V8CustomButton
236+
label={t("Reset Password")}
237+
variant="secondary"
238+
dataTestId="profile-reset-password"
239+
ariaLabel={t("Reset Password")}
240+
disabled={!resetPasswordUrl}
241+
onClick={() => {
242+
if (!resetPasswordUrl) return;
243+
window.open(resetPasswordUrl, "_blank", "noopener,noreferrer");
244+
}}
245+
/>
246+
</div>
247+
<div className="col-12">
248+
<CustomInfo
249+
className="profile-settings-note-panel"
250+
variant="secondary"
251+
icon={<ApplicationLogo width="1.1875rem" height="1.4993rem" />}
252+
content={t("Success! Check your email inbox for next steps.")}
253+
/>
254+
</div>
255+
</div>
256+
</div>
257+
258+
<SelectDropdown
259+
options={selectLanguages.map((lang) => ({
260+
label: lang.value,
261+
value: lang.name,
262+
}))}
263+
width="22rem"
264+
defaultValue={selectedLangLabel}
265+
dataTestId="settings-language-dropdown"
266+
ariaLabel={t("Language Dropdown")}
267+
value={selectedLangLabel}
268+
variant="primary"
269+
className="mb-3"
270+
onChange={handleLanguageChange}
271+
/>
272+
</>
273+
) : (
274+
<div></div>
275+
)}
112276
{tenantId && daysDifference !== null ? (
113277
<CustomInfo
114278
className="note"
@@ -123,20 +287,14 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
123287
<Modal.Footer>
124288
<div className="buttons-row">
125289
<V8CustomButton
126-
label={t("Save Changes")}
290+
label={t("Update")}
127291
onClick={handleConfirmProfile}
128292
dataTestId="save-profile-settings"
129293
ariaLabel={t("Save Profile Settings")}
130-
disabled={isSaveDisabled}
294+
disabled={activeTab !== "Profile" || !isAnythingChanged}
131295
variant="primary"
132296
/>
133-
<V8CustomButton
134-
label={t("Cancel")}
135-
onClick={onClose}
136-
dataTestId="cancel-profile-settings"
137-
ariaLabel={t("Cancel profile settings")}
138-
variant="secondary"
139-
/>
297+
140298
</div>
141299
</Modal.Footer>
142300
</Modal>

0 commit comments

Comments
 (0)