From ba465ab2fbb8b6a5cf8b94efd0227a70020b7423 Mon Sep 17 00:00:00 2001 From: Samrat Biswas Date: Fri, 28 Nov 2025 02:34:52 +0600 Subject: [PATCH 1/3] Update SearchBar styles for improved visibility Signed-off-by: Samrat Biswas --- .../src/pages/Search/components/SearchBar.scss | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/application/frontend/src/pages/Search/components/SearchBar.scss b/application/frontend/src/pages/Search/components/SearchBar.scss index f42a54e1..3dc901dd 100644 --- a/application/frontend/src/pages/Search/components/SearchBar.scss +++ b/application/frontend/src/pages/Search/components/SearchBar.scss @@ -1,10 +1,10 @@ .navbar__search { - display: none; + display: none; align-items: center; - @media (min-width: 1024px) { display: flex; } + .navbar__mobile-menu &, .mobile-search-container & { display: block !important; @@ -23,6 +23,8 @@ color: var(--muted-foreground); height: 1rem; width: 1rem; + display: block; + z-index: 2; } input { @@ -30,7 +32,7 @@ width: 16rem; background-color: rgba(var(--card-rgb), 0.5); backdrop-filter: blur(4px); - border: 1px solid rgba(var(--border-rgb), 0.5); + border: 1px solid #64748b; border-radius: var(--radius); color: var(--foreground); transition: all 0.2s ease; @@ -41,14 +43,14 @@ &:focus { outline: none; - border-color: transparent; - box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #3b82f6; + border-color: #3b82f6; + box-shadow: 0 0 0 1px #ffffff, 0 0 0 1px #3b82f6; } } .search-error { margin-top: 0.25rem; font-size: 0.75rem; - color: #f87171; // red-400 + color: #f87171; } } From 666be591c533a54318dde26dd74b092ded97ca25 Mon Sep 17 00:00:00 2001 From: Samrat Biswas Date: Fri, 28 Nov 2025 02:43:17 +0600 Subject: [PATCH 2/3] Refactor SearchBar styles for better responsiveness Signed-off-by: Samrat Biswas --- .../src/pages/Search/components/SearchBar.scss | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/application/frontend/src/pages/Search/components/SearchBar.scss b/application/frontend/src/pages/Search/components/SearchBar.scss index 3dc901dd..f42a54e1 100644 --- a/application/frontend/src/pages/Search/components/SearchBar.scss +++ b/application/frontend/src/pages/Search/components/SearchBar.scss @@ -1,10 +1,10 @@ .navbar__search { - display: none; + display: none; align-items: center; + @media (min-width: 1024px) { display: flex; } - .navbar__mobile-menu &, .mobile-search-container & { display: block !important; @@ -23,8 +23,6 @@ color: var(--muted-foreground); height: 1rem; width: 1rem; - display: block; - z-index: 2; } input { @@ -32,7 +30,7 @@ width: 16rem; background-color: rgba(var(--card-rgb), 0.5); backdrop-filter: blur(4px); - border: 1px solid #64748b; + border: 1px solid rgba(var(--border-rgb), 0.5); border-radius: var(--radius); color: var(--foreground); transition: all 0.2s ease; @@ -43,14 +41,14 @@ &:focus { outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 1px #ffffff, 0 0 0 1px #3b82f6; + border-color: transparent; + box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #3b82f6; } } .search-error { margin-top: 0.25rem; font-size: 0.75rem; - color: #f87171; + color: #f87171; // red-400 } } From 12ac4c5a54932639fa0654859157081caf44ad3d Mon Sep 17 00:00:00 2001 From: Biswas-Samrat Date: Sat, 6 Dec 2025 04:36:17 +0600 Subject: [PATCH 3/3] Feat: Migrate UI framework from Semantic-UI to TailwindCSS V3 (Closes #648) --- application/frontend/src/App.tsx | 2 + application/frontend/src/app.scss | 6 + .../components/DocumentNode/DocumentNode.tsx | 131 +- .../components/DocumentNode/documentNode.scss | 59 - .../pages/BrowseRootCres/browseRootCres.scss | 37 - .../pages/BrowseRootCres/browseRootCres.tsx | 30 +- .../CommonRequirementEnumeration.tsx | 87 +- .../commonRequirementEnumeration.scss | 74 - .../src/pages/Explorer/LinkedStandards.scss | 19 - .../src/pages/Explorer/LinkedStandards.tsx | 89 +- .../frontend/src/pages/Explorer/explorer.scss | 112 - .../frontend/src/pages/Explorer/explorer.tsx | 394 +- .../Explorer/visuals/circles/circles.scss | 61 - .../Explorer/visuals/circles/circles.tsx | 107 +- .../visuals/force-graph/forceGraph.scss | 3 - .../visuals/force-graph/forceGraph.tsx | 215 +- .../src/pages/GapAnalysis/GapAnalysis.scss | 17 - .../src/pages/GapAnalysis/GapAnalysis.tsx | 429 +- .../MembershipRequired.scss | 8 - .../MembershipRequired/MembershipRequired.tsx | 28 +- .../frontend/src/pages/Search/Search.tsx | 403 +- .../pages/Search/components/SearchBar.scss | 54 - .../src/pages/Search/components/SearchBar.tsx | 41 +- .../pages/Search/components/SearchResults.tsx | 16 +- .../pages/Search/components/ui/button.scss | 107 - .../src/pages/Search/components/ui/button.tsx | 32 +- .../src/pages/Search/components/ui/input.scss | 46 - .../src/pages/Search/components/ui/input.tsx | 7 +- .../src/pages/Search/components/ui/toast.scss | 232 - .../src/pages/Search/components/ui/toast.tsx | 47 +- .../frontend/src/pages/Search/search.scss | 1124 --- .../frontend/src/pages/Standard/Standard.tsx | 87 +- .../src/pages/Standard/StandardSection.tsx | 101 +- .../frontend/src/pages/Standard/standard.scss | 34 - .../frontend/src/pages/chatbot/chatbot.scss | 78 - .../frontend/src/pages/chatbot/chatbot.tsx | 237 +- application/frontend/src/routes.tsx | 14 +- .../src/scaffolding/Header/Header.tsx | 244 +- .../src/scaffolding/Header/header.scss | 303 - .../src/scaffolding/NoRoute/NoRoute.tsx | 25 +- .../src/scaffolding/NoRoute/noRoute.scss | 7 - package.json | 8 + postcss.config.js | 6 + tailwind.config.js | 11 + tsconfig.json | 5 +- webpack.config.js | 114 +- webpack.prod.js | 104 +- yarn.lock | 8801 +++++++++-------- 48 files changed, 6794 insertions(+), 7402 deletions(-) delete mode 100644 application/frontend/src/components/DocumentNode/documentNode.scss delete mode 100644 application/frontend/src/pages/BrowseRootCres/browseRootCres.scss delete mode 100644 application/frontend/src/pages/CommonRequirementEnumeration/commonRequirementEnumeration.scss delete mode 100644 application/frontend/src/pages/Explorer/LinkedStandards.scss delete mode 100644 application/frontend/src/pages/Explorer/explorer.scss delete mode 100644 application/frontend/src/pages/Explorer/visuals/circles/circles.scss delete mode 100644 application/frontend/src/pages/Explorer/visuals/force-graph/forceGraph.scss delete mode 100644 application/frontend/src/pages/GapAnalysis/GapAnalysis.scss delete mode 100644 application/frontend/src/pages/MembershipRequired/MembershipRequired.scss delete mode 100644 application/frontend/src/pages/Search/components/SearchBar.scss delete mode 100644 application/frontend/src/pages/Search/components/ui/button.scss delete mode 100644 application/frontend/src/pages/Search/components/ui/input.scss delete mode 100644 application/frontend/src/pages/Search/components/ui/toast.scss delete mode 100644 application/frontend/src/pages/Search/search.scss delete mode 100644 application/frontend/src/pages/Standard/standard.scss delete mode 100644 application/frontend/src/pages/chatbot/chatbot.scss delete mode 100644 application/frontend/src/scaffolding/Header/header.scss delete mode 100644 application/frontend/src/scaffolding/NoRoute/noRoute.scss create mode 100644 postcss.config.js create mode 100644 tailwind.config.js diff --git a/application/frontend/src/App.tsx b/application/frontend/src/App.tsx index 1913e46a..0c18c5b9 100755 --- a/application/frontend/src/App.tsx +++ b/application/frontend/src/App.tsx @@ -23,6 +23,8 @@ const App = () => ( + + ); diff --git a/application/frontend/src/app.scss b/application/frontend/src/app.scss index 3ac512a9..eaa8667c 100644 --- a/application/frontend/src/app.scss +++ b/application/frontend/src/app.scss @@ -1,3 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + + * { box-sizing: border-box; diff --git a/application/frontend/src/components/DocumentNode/DocumentNode.tsx b/application/frontend/src/components/DocumentNode/DocumentNode.tsx index deb877f2..17f3475c 100644 --- a/application/frontend/src/components/DocumentNode/DocumentNode.tsx +++ b/application/frontend/src/components/DocumentNode/DocumentNode.tsx @@ -1,10 +1,6 @@ -import './documentNode.scss'; - import axios from 'axios'; import React, { FunctionComponent, useContext, useEffect, useMemo, useState } from 'react'; import { Link, useHistory } from 'react-router-dom'; -import { Icon } from 'semantic-ui-react'; - import { TYPE_AUTOLINKED_TO, TYPE_CONTAINS, TYPE_IS_PART_OF, TYPE_RELATED } from '../../const'; import { useEnvironment } from '../../hooks'; import { applyFilters } from '../../hooks/applyFilters'; @@ -13,6 +9,10 @@ import { getDocumentDisplayName, groupLinksByType } from '../../utils'; import { getApiEndpoint, getDocumentTypeText, getInternalUrl } from '../../utils/document'; import { FilterButton } from '../FilterButton/FilterButton'; import { LoadingAndErrorIndicator } from '../LoadingAndErrorIndicator'; +import { ChevronDown, Circle, ExternalLink, ChevronRight } from 'lucide-react'; + + + export interface DocumentNode { node: Document; @@ -89,7 +89,7 @@ export const DocumentNode: FunctionComponent = ({ return ( <> Reference: - + {' '} {hyperlink.hyperlink} @@ -103,22 +103,68 @@ export const DocumentNode: FunctionComponent = ({ } return ( - - + { + e.currentTarget.style.color = '#115c96'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#2185d0'; + }} + > + ); }; + const SimpleView = () => { + const [isHovered, setIsHovered] = useState(false); + return ( <> -
- - - {getDocumentDisplayName(usedNode)} +
+ setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {getDocumentDisplayName(usedNode)} - + {hasExternalLink && }
-
+
); }; @@ -127,9 +173,44 @@ export const DocumentNode: FunctionComponent = ({ return ( <> -
setExpanded(!expanded)}> - - {getDocumentDisplayName(usedNode)} +
+
setExpanded(!expanded)} + style={{ + display: 'inline-flex', + alignItems: 'center', + flex: 1, + }} + > + + + {getDocumentDisplayName(usedNode)} + +
@@ -140,14 +221,23 @@ export const DocumentNode: FunctionComponent = ({ ); let lastDocumentName = sortedResults[0].document.name; return ( -
- {idx > 0 &&
} +
+ {idx > 0 && ( +
+ )}
Which {getDocumentTypeText(type, links[0].document.doctype, node.doctype)}: - {/* Risk here of mixed doctype in here causing odd output */}
-
+
{sortedResults.map((link, i) => { const temp = (
@@ -169,7 +259,6 @@ export const DocumentNode: FunctionComponent = ({
); })} - {/* */}
); diff --git a/application/frontend/src/components/DocumentNode/documentNode.scss b/application/frontend/src/components/DocumentNode/documentNode.scss deleted file mode 100644 index 27964cc8..00000000 --- a/application/frontend/src/components/DocumentNode/documentNode.scss +++ /dev/null @@ -1,59 +0,0 @@ -.document-node { - .icon.circle { - font-size: 0.5em; - position: relative; - text-align: center; - margin: 0 0.6rem 0 0.3rem; - padding: 0 0.5rem 0 0; - } - - .icon.external { - color: #2185d0; - font-size: 0.9em; - padding-left: 0.5em; - } - -.external:hover::before { - color: #115c96; - padding: 1px; - } - - .external-link, - &.external-link { - a { - color: rgba(0, 0, 0, 0.4); - } - } - - .external-link:hover, - &.external-link:hover { - a { - color: rgba(0, 0, 0, 0.87); - } - } - - &__link-type-container hr { - margin: 15px 0; - } -} - - -.document-node__link-type-container .accordion.ui.styled -{ - div>.title.external-link { - padding-top: 0; - padding-bottom: .25em; - } - div:first-child>.title.external-link { - padding-top: .75em; - padding-bottom: .25em; - } - - div>span+.title.external-link { - border-top: none; - } - - div:last-child>.title.external-link { - padding-bottom: .75em; - } -} diff --git a/application/frontend/src/pages/BrowseRootCres/browseRootCres.scss b/application/frontend/src/pages/BrowseRootCres/browseRootCres.scss deleted file mode 100644 index 5206d123..00000000 --- a/application/frontend/src/pages/BrowseRootCres/browseRootCres.scss +++ /dev/null @@ -1,37 +0,0 @@ -.cre-page { - padding: 30px; - margin: var(--header-height) 0; - - - &__links-container { - margin-top: 10px; - } -} - -.cre-page .cre-page { - &__heading { - font-size: 2rem; - margin-bottom: 0px; - } - &__sub-heading { - color: #999; - margin-top: 0px; - font-size: 1.2rem; - } - - &__description { - width: 50%; - } - - &__links-header { - margin-bottom: 10px; - } - - &__links { - padding-top: 10px; - } - - &__links:not(:first-child) { - padding-top: 40px; - } -} diff --git a/application/frontend/src/pages/BrowseRootCres/browseRootCres.tsx b/application/frontend/src/pages/BrowseRootCres/browseRootCres.tsx index 595a7a7e..d94caa35 100644 --- a/application/frontend/src/pages/BrowseRootCres/browseRootCres.tsx +++ b/application/frontend/src/pages/BrowseRootCres/browseRootCres.tsx @@ -1,8 +1,5 @@ -import './browseRootCres.scss'; - import axios from 'axios'; import React, { useContext, useEffect, useMemo, useState } from 'react'; - import { DocumentNode } from '../../components/DocumentNode'; import { ClearFilterButton, FilterButton } from '../../components/FilterButton/FilterButton'; import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; @@ -12,6 +9,8 @@ import { Document } from '../../types'; import { groupLinksByType } from '../../utils'; import { SearchResults } from '../Search/components/SearchResults'; + + export const BrowseRootCres = () => { const { apiUrl } = useEnvironment(); const [loading, setLoading] = useState(false); @@ -39,13 +38,30 @@ export const BrowseRootCres = () => { setLoading(false); }); }, []); + return ( -
-

Root CREs

+
+

+ Root CREs +

{!loading && !error && ( -
-
{display && }
+
+ {display && }
)}
diff --git a/application/frontend/src/pages/CommonRequirementEnumeration/CommonRequirementEnumeration.tsx b/application/frontend/src/pages/CommonRequirementEnumeration/CommonRequirementEnumeration.tsx index 99f4e713..1eb1b57a 100644 --- a/application/frontend/src/pages/CommonRequirementEnumeration/CommonRequirementEnumeration.tsx +++ b/application/frontend/src/pages/CommonRequirementEnumeration/CommonRequirementEnumeration.tsx @@ -1,9 +1,6 @@ -import './commonRequirementEnumeration.scss'; - import axios from 'axios'; import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; - import { DocumentNode } from '../../components/DocumentNode'; import { ClearFilterButton, FilterButton } from '../../components/FilterButton/FilterButton'; import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; @@ -52,26 +49,63 @@ export const CommonRequirementEnumeration = () => { display = currentUrlParams.get('applyFilters') === 'true' ? filteredCRE : cre; const linksByType = useMemo(() => (display ? orderLinksByType(groupLinksByType(display)) : {}), [display]); + return ( -
+
{!loading && !error && display && ( <> -

{display.name}

-
CRE: {display.id}
-
{display.description}
+

+ {display.name} +

+
+ CRE: {display.id} +
+
+ {display.description} +
{display && display.hyperlink && ( <> Reference: - - {' '} + {display.hyperlink} )} {currentUrlParams.get('applyFilters') === 'true' ? ( -
+
Filtering on:{' '} {currentUrlParams.getAll('filters').map((filter) => ( {filter.replace('s:', '').replace('c:', '')}, @@ -81,7 +115,10 @@ export const CommonRequirementEnumeration = () => { ) : ( '' )} -
+
{Object.keys(linksByType).length > 0 && Object.entries(linksByType).map(([type, links]) => { const sortedResults = links.sort((a, b) => @@ -89,14 +126,34 @@ export const CommonRequirementEnumeration = () => { ); let lastDocumentName = sortedResults[0].document.name; return ( -
-
+
+
Which {getDocumentTypeText(type, links[0].document.doctype)}: - {/* Risk of mixed doctype in here causing odd output */}
{sortedResults.map((link, i) => { const temp = ( -
+
{lastDocumentName !== link.document.name && } diff --git a/application/frontend/src/pages/CommonRequirementEnumeration/commonRequirementEnumeration.scss b/application/frontend/src/pages/CommonRequirementEnumeration/commonRequirementEnumeration.scss deleted file mode 100644 index 2c92d5c5..00000000 --- a/application/frontend/src/pages/CommonRequirementEnumeration/commonRequirementEnumeration.scss +++ /dev/null @@ -1,74 +0,0 @@ -.cre-page { - padding: 30px; - margin: var(--header-height) 0; - - &__links-container { - margin-top: 10px; - } -} - -.cre-page .cre-page { - &__heading { - font-size: 2rem; - margin-bottom: 0px; - } - - &__sub-heading { - color: #999; - margin-top: 0px; - font-size: 1.2rem; - } - - &__description { - width: 50%; - } - - &__links-header { - margin-bottom: 10px; - } - - &__links { - padding-top: 10px; - } - - &__links:not(:first-child) { - padding-top: 20px; - } -} - -.cre-page__links-container.accordion.ui.styled { - padding-top: 0; - border: none; - border-radius: 0; - margin-top: 0; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, .15), 0 1px 0 1px rgba(34, 36, 38, .15); -} - -.cre-page__links-container.accordion.ui.styled:nth-child(2) { - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, .15), 0 0 0 1px rgba(34, 36, 38, .15); - border-radius: 0.28571429rem 0; - >.title.external-link { - padding-top: .75em; - padding-bottom: .25em; - } -} - -.cre-page__links-container.accordion.ui.styled:last-child { - border-radius: 0 0.28571429rem; - >.title.external-link { - padding-bottom: .75em; - } -} - -.cre-page__links-container.accordion.ui.styled -{ - >.title.external-link { - padding-top: 0; - padding-bottom: .25em; - } - - >span+.title.external-link { - border-top: none; - } - -} diff --git a/application/frontend/src/pages/Explorer/LinkedStandards.scss b/application/frontend/src/pages/Explorer/LinkedStandards.scss deleted file mode 100644 index bb9901a4..00000000 --- a/application/frontend/src/pages/Explorer/LinkedStandards.scss +++ /dev/null @@ -1,19 +0,0 @@ -main#explorer-content { - .tags { - display: flex; - gap: 4px; - height: 100%; - justify-content: center; - align-items: center; - - .ui.label { - border: 1px solid #2185d0; - text-shadow: none; - } - - a:hover .label { - background: #4183c4; - color: white; - } - } -} diff --git a/application/frontend/src/pages/Explorer/LinkedStandards.tsx b/application/frontend/src/pages/Explorer/LinkedStandards.tsx index 87ffaff1..4e56db6a 100644 --- a/application/frontend/src/pages/Explorer/LinkedStandards.tsx +++ b/application/frontend/src/pages/Explorer/LinkedStandards.tsx @@ -1,12 +1,22 @@ -import './LinkedStandards.scss'; - import React, { Fragment } from 'react'; import { Link } from 'react-router-dom'; -import { Icon, Label, List } from 'semantic-ui-react'; +import { ExternalLink } from 'lucide-react'; import { LinkedTreeDocument } from '../../types'; -export const LinkedStandards = ({ creCode, linkedTo, applyHighlight, filter }) => { +interface LinkedStandardsProps { + creCode: string; + linkedTo: LinkedTreeDocument[]; + applyHighlight: (text: string, filter: string) => React.ReactNode; + filter: string; +} + +export const LinkedStandards: React.FC = ({ + creCode, + linkedTo, + applyHighlight, + filter +}) => { /** * Get a link to a filtered version of the CRE to show the relevant standards */ @@ -48,26 +58,77 @@ export const LinkedStandards = ({ creCode, linkedTo, applyHighlight, filter }) = const uniqueLinkedTo = getUniqueByName(linkedTo); return ( - - +
+
{uniqueLinkedTo.map((x: LinkedTreeDocument) => ( {isExternalLink(x, linkedTo) && ( - - + { + e.currentTarget.style.backgroundColor = '#4183c4'; + e.currentTarget.style.color = 'white'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = '#fff'; + e.currentTarget.style.color = '#2185d0'; + }} + > + {applyHighlight(x.document.name, filter)} - + )} {!isExternalLink(x, linkedTo) && ( - - + + { + e.currentTarget.style.backgroundColor = '#4183c4'; + e.currentTarget.style.color = 'white'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = '#fff'; + e.currentTarget.style.color = '#2185d0'; + }} + > + {applyHighlight(x.document.name, filter)} + )} ))} - - +
+
); }; diff --git a/application/frontend/src/pages/Explorer/explorer.scss b/application/frontend/src/pages/Explorer/explorer.scss deleted file mode 100644 index dae6bf27..00000000 --- a/application/frontend/src/pages/Explorer/explorer.scss +++ /dev/null @@ -1,112 +0,0 @@ -main#explorer-content { - padding: 30px; - margin: var(--header-height) 0; - - .search-field { - input { - font-size: 16px; - height: 32px; - width: 320px; - margin-bottom: 10px; - border-radius: 3px; - border: 1px solid #858585; - padding: 0 5px; - } - } - - #graphs-menu { - display: flex; - margin-bottom: 20px; - .menu-title { - margin: 0 10px 0 0; - } - ul { - list-style: none; - padding: 0; - margin: 0; - display: flex; - font-size: 15px; - } - li { - padding: 0 8px; - + li { - border-left: 1px solid #b1b0b0; - } - &:first-child { - padding-left: 0; - } - } - } - - .list { - padding: 0; - width: 100%; - } - - .arrow { - .icon { - transform: rotate(-90deg); - } - &.active { - .icon { - transform: rotate(0); - } - } - &:hover { - cursor: pointer; - } - } - - .item { - border-top: 1px dotted lightgrey; - border-left: 6px solid lightgrey; - margin: 4px 4px 4px 40px; - background-color: rgba(200, 200, 200, 0.2); - vertical-align: middle; - - .content { - display: flex; - flex-wrap: wrap; - } - - .header { - margin-left: 5px; - overflow: hidden; - white-space: nowrap; - display: flex; - align-items: center; - vertical-align: middle; - - > a { - font-size: 120%; - line-height: 30px; - font-weight: bold; - max-width: 100%; - white-space: normal; - } - - .cre-code { - margin-right: 4px; - color: grey; - } - } - - .description { - margin-left: 20px; - } - } - - .highlight { - background-color: yellow; - } - - > .list > .item { - margin-left: 0; - } -} - -@media (min-width: 0px) and (max-width: 770px) { - #graphs-menu { - flex-direction: column; - } -} diff --git a/application/frontend/src/pages/Explorer/explorer.tsx b/application/frontend/src/pages/Explorer/explorer.tsx index c85fc3b3..68095b1c 100644 --- a/application/frontend/src/pages/Explorer/explorer.tsx +++ b/application/frontend/src/pages/Explorer/explorer.tsx @@ -1,9 +1,5 @@ -import './explorer.scss'; - import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { List } from 'semantic-ui-react'; - import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; import { TYPE_CONTAINS, TYPE_LINKED_TO } from '../../const'; import { useDataStore } from '../../providers/DataProvider'; @@ -12,6 +8,9 @@ import { getDocumentDisplayName } from '../../utils'; import { getInternalUrl } from '../../utils/document'; import { LinkedStandards } from './LinkedStandards'; + + + export const Explorer = () => { const { dataLoading, dataTree } = useDataStore(); const [loading, setLoading] = useState(false); @@ -24,7 +23,7 @@ export const Explorer = () => { return ( <> {text.substring(0, index)} - {text.substring(index, index + term.length)} + {text.substring(index, index + term.length)} {text.substring(index + term.length)} ); @@ -48,12 +47,11 @@ export const Explorer = () => { } if (filterFunc(doc, term) || doc.links?.length) { - return doc; // Return the document if it or any of its children (links or standards) matches the term + return doc; } - return null; // Return null if the document and its descendants do not match the term + return null; }; - //accordion const [collapsedItems, setCollapsedItems] = useState([]); const isCollapsed = (id: string) => collapsedItems.includes(id); const toggleItem = (id: string) => { @@ -83,7 +81,7 @@ export const Explorer = () => { setLoading(dataLoading); }, [dataLoading]); - function processNode(item) { + function processNode(item, depth = 0) { if (!item) { return <>; } @@ -96,34 +94,65 @@ export const Explorer = () => { const creCode = item.id; const creName = item.displayName.split(' : ').pop(); + return ( - - - +
  • 0 ? '40px' : '0', + paddingLeft: '8px', + borderLeft: depth > 0 ? '4px solid #ddd' : 'none', + marginTop: '4px', + marginBottom: '4px', + backgroundColor: depth % 2 === 0 ? '#f9f9f9' : '#ffffff' + }} + > +
    +
    {contains.length > 0 && ( -
    toggleItem(item.id)} - > - -
    + style={{ + cursor: 'pointer', + display: 'inline-block', + width: '0', + height: '0', + marginRight: '8px', + borderTop: isCollapsed(item.id) ? '6px solid transparent' : '6px solid #333', + borderBottom: isCollapsed(item.id) ? '6px solid transparent' : '0', + borderLeft: isCollapsed(item.id) ? '6px solid #333' : '6px solid transparent', + borderRight: isCollapsed(item.id) ? '0' : '6px solid transparent', + transition: 'all 0.2s' + }} + /> )} - - {applyHighlight(creCode, filter)}: - {applyHighlight(creName, filter)} + + {applyHighlight(creCode, filter)}: + {applyHighlight(creName, filter)} - +
    - {contains.length > 0 && !isCollapsed(item.id) && ( - {contains.map((child) => processNode(child.document))} - )} - - +
    + {contains.length > 0 && !isCollapsed(item.id) && ( +
      + {contains.map((child) => processNode(child.document, depth + 1))} +
    + )} +
  • ); } @@ -133,49 +162,310 @@ export const Explorer = () => { return ( <> -
    -

    Open CRE Explorer

    -

    +

    +

    Open CRE Explorer

    +

    A visual explorer of Open Common Requirement Enumerations (CREs). Originally created by:{' '} - + Zeljko Obrenovic .

    -
    - +
    +
    - - +
      {filteredTree?.map((item) => { - return processNode(item); + return processNode(item, 0); })} - +
    ); }; + + + + + + + + +// import React, { useEffect, useState } from 'react'; +// import { Link } from 'react-router-dom'; + +// import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; +// import { TYPE_CONTAINS, TYPE_LINKED_TO } from '../../const'; +// import { useDataStore } from '../../providers/DataProvider'; +// import { LinkedTreeDocument, TreeDocument } from '../../types'; +// import { getDocumentDisplayName } from '../../utils'; +// import { getInternalUrl } from '../../utils/document'; +// import { LinkedStandards } from './LinkedStandards'; + +// export const Explorer = () => { +// const { dataLoading, dataTree } = useDataStore(); +// const [loading, setLoading] = useState(false); +// const [filter, setFilter] = useState(''); +// const [filteredTree, setFilteredTree] = useState(); + +// const applyHighlight = (text: string, term: string | undefined) => { +// if (!term) return text; +// const lower = text?.toLowerCase() ?? ''; +// const idx = lower.indexOf(term); +// if (idx >= 0) { +// return ( +// <> +// {text.substring(0, idx)} +// {text.substring(idx, idx + term.length)} +// {text.substring(idx + term.length)} +// +// ); +// } +// return text; +// }; + +// const filterFunc = (doc: TreeDocument, term: string) => +// doc?.displayName?.toLowerCase().includes(term) || doc?.name?.toLowerCase().includes(term); + +// const recursiveFilter = (doc: TreeDocument, term: string) => { +// if (!doc) return null; +// if (doc.links) { +// const filteredLinks: LinkedTreeDocument[] = []; +// doc.links.forEach((x) => { +// const filteredDoc = recursiveFilter(x.document, term); +// if (filterFunc(x.document, term) || filteredDoc) { +// filteredLinks.push({ ltype: x.ltype, document: (filteredDoc as TreeDocument) || x.document }); +// } +// }); +// doc = { ...doc, links: filteredLinks }; +// } + +// if (filterFunc(doc, term) || doc.links?.length) { +// return doc; +// } +// return null; +// }; + +// const [collapsedItems, setCollapsedItems] = useState([]); +// const isCollapsed = (id: string) => collapsedItems.includes(id); +// const toggleItem = (id: string) => { +// if (collapsedItems.includes(id)) { +// setCollapsedItems(collapsedItems.filter((itemId) => itemId !== id)); +// } else { +// setCollapsedItems([...collapsedItems, id]); +// } +// }; + +// useEffect(() => { +// if (dataTree.length) { +// const treeCopy = structuredClone(dataTree); +// const filTree: TreeDocument[] = []; +// treeCopy +// .map((x: TreeDocument) => recursiveFilter(x, filter)) +// .forEach((x: TreeDocument | null) => { +// if (x) { +// filTree.push(x); +// } +// }); +// setFilteredTree(filTree); +// } else { +// setFilteredTree([]); +// } +// }, [filter, dataTree, setFilteredTree]); + +// useEffect(() => { +// setLoading(dataLoading); +// }, [dataLoading]); + +// function processNode(item: TreeDocument | null) { +// if (!item) { +// return <>; +// } + +// item.displayName = item.displayName ?? getDocumentDisplayName(item); +// item.url = item.url ?? getInternalUrl(item); +// item.links = item.links ?? []; + +// const contains = item.links.filter((x) => x.ltype === TYPE_CONTAINS); +// const linkedTo = item.links.filter((x) => x.ltype === TYPE_LINKED_TO); + +// const creCode = item.id; +// const creName = item.displayName.split(' : ').pop(); + +// return ( +//
  • +//
    +//
    +// {contains.length > 0 && ( +// +// )} + +//
    +//

    +// +// {applyHighlight(creCode, filter)}: +// {applyHighlight(String(creName), filter)} +// +//

    + +//
    +// +//
    +//
    +//
    + +// {contains.length > 0 && !isCollapsed(item.id) && ( +//
      +// {contains.map((child) => ( +// {processNode(child.document)} +// ))} +//
    +// )} +//
    +//
  • +// ); +// } + +// function update(event: React.KeyboardEvent | React.ChangeEvent) { +// const target = event.target as HTMLInputElement; +// setFilter(target.value.toLowerCase()); +// } + +// return ( +// <> +//
    +//

    Open CRE Explorer

    +//

    +// A visual explorer of Open Common Requirement Enumerations (CREs). Originally created by:{' '} +// +// Zeljko Obrenovic +// +// . +//

    +//
    +//
    +// +//
    +//
    +//
    Explore visually:
    + +// +// +// Dependency Graph +// + + + +// +// Zoomable circles +// +// +//
    +//
    + +//
    + +// + +//
      +// {filteredTree?.map((item) => { +// return processNode(item); +// })} +//
    +//
    +// +// ); +// }; diff --git a/application/frontend/src/pages/Explorer/visuals/circles/circles.scss b/application/frontend/src/pages/Explorer/visuals/circles/circles.scss deleted file mode 100644 index a2ec2f1f..00000000 --- a/application/frontend/src/pages/Explorer/visuals/circles/circles.scss +++ /dev/null @@ -1,61 +0,0 @@ -.node { - cursor: pointer; -} - -.node:hover { - stroke: #000; - stroke-width: 1.5px; -} - -.node--leaf { - fill: white; -} - -.label { - font: 11px 'Helvetica Neue', Helvetica, Arial, sans-serif; - text-anchor: middle; - text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff; -} - -.label, -.node--root, -// .node--leaf { -// pointer-events: none; -// } - -.ui.button.screen-size-button { - /* position: absolute; */ - /* right: 0; */ - margin: 0; - background-color: transparent; -} - -.circle-tooltip { - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - max-width: 300px; - word-wrap: break-word; -} -.breadcrumb-item { - word-break: break-word; // Break long words in breadcrumb items - overflow-wrap: anywhere; - display: inline; // Or inline-block if you want padding/margin to apply - max-width: 100%; // Prevent overflow -} -.breadcrumb-container { - margin-top: 0 !important; - margin-bottom: 0 !important; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - padding: 10px; - background-color: #f8f8f8; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - - overflow: auto; - max-width: 100%; - - line-height: 1.4; - white-space: normal; // Allow wrapping - word-break: break-word; // Break long words if needed - overflow-wrap: anywhere; -} diff --git a/application/frontend/src/pages/Explorer/visuals/circles/circles.tsx b/application/frontend/src/pages/Explorer/visuals/circles/circles.tsx index 8083b9da..93093a3e 100644 --- a/application/frontend/src/pages/Explorer/visuals/circles/circles.tsx +++ b/application/frontend/src/pages/Explorer/visuals/circles/circles.tsx @@ -1,11 +1,11 @@ -import './circles.scss'; - import { LoadingAndErrorIndicator } from 'application/frontend/src/components/LoadingAndErrorIndicator'; import useWindowDimensions from 'application/frontend/src/hooks/useWindowDimensions'; import { useDataStore } from 'application/frontend/src/providers/DataProvider'; import * as d3 from 'd3'; import React, { useEffect, useState } from 'react'; -import { Button, Icon } from 'semantic-ui-react'; +import { Maximize, Minimize, Plus, Minus } from 'lucide-react'; + + export const ExplorerCircles = () => { const { height, width } = useWindowDimensions(); @@ -26,7 +26,6 @@ export const ExplorerCircles = () => { useEffect(() => { if (!svgRef.current) { - // guard to ensure the element exists return; } var svg = d3.select(svgRef.current); @@ -76,7 +75,6 @@ export const ExplorerCircles = () => { nodes = pack(root).descendants(), view; - // Create tooltip div for hover labels const tooltip = d3 .select('body') .append('div') @@ -88,9 +86,12 @@ export const ExplorerCircles = () => { .style('border-radius', '3px') .style('border', '1px solid #ccc') .style('pointer-events', 'none') - .style('z-index', '10'); + .style('z-index', '10') + .style('box-shadow', '0 2px 5px rgba(0, 0, 0, 0.2)') + .style('font-family', "'Helvetica Neue', Helvetica, Arial, sans-serif") + .style('max-width', '300px') + .style('word-wrap', 'break-word'); - // Update breadcrumb when focus changes const updateBreadcrumb = (d: any) => { if (d === root) { setBreadcrumb(['OpenCRE']); @@ -102,7 +103,6 @@ export const ExplorerCircles = () => { while (current && current !== root) { if (current.data.displayName && current.data.displayName !== 'OpenCRE') { - // Remove "CRE: " prefix if it exists const displayName = current.data.displayName.replace(/^CRE: /, ''); path.unshift(displayName); } @@ -121,23 +121,22 @@ export const ExplorerCircles = () => { return d.parent ? (d.children ? 'node' : 'node node--leaf') : 'node node--root'; }) .style('fill', function (d: any) { - return d.children ? color(d.depth) : d.data.color ? d.data.color : null; + return d.children ? color(d.depth) : d.data.color ? d.data.color : 'white'; }) .style('cursor', function (d) { - // Show the pointer cursor only if it's a leaf node AND has a hyperlink property. if (!d.children && (d.data as { hyperlink?: string }).hyperlink) { return 'pointer'; } - return 'default'; + return d.children ? 'pointer' : 'default'; }) - .on('mouseover', function (event, d: any) { - // Prefer displayName, fallback to id + d3.select(this).style('stroke', '#000').style('stroke-width', '1.5px'); + const label = d.data.displayName ? d.data.displayName.replace(/^CRE: /, '') : d.data.id - ? d.data.id - : ''; + ? d.data.id + : ''; if (label) { tooltip @@ -151,17 +150,13 @@ export const ExplorerCircles = () => { tooltip.style('top', event.pageY - 10 + 'px').style('left', event.pageX + 10 + 'px'); }) .on('mouseout', function () { + d3.select(this).style('stroke', null).style('stroke-width', null); tooltip.style('visibility', 'hidden'); }) - .on('click', function (event, d: any) { if (!d.children) { event.stopPropagation(); - - // Directly access the hyperlink property from the node's data. const url = d.data.hyperlink; - - // If the url exists, open it in a new tab. if (url) { console.log('URL found:', url); window.open(url, '_blank'); @@ -174,17 +169,16 @@ export const ExplorerCircles = () => { event.stopPropagation(); } }); + let showLabels = true; - // Filter the nodes to only include those that have children (i.e., are not leaves) const parentNodes = nodes.filter(function (d) { return d.children; }); - // Create a group for the label components using ONLY the parent nodes var labelGroup = g .selectAll('.label-group') - .data(parentNodes) // Use the filtered data + .data(parentNodes) .enter() .append('g') .attr('class', 'label-group') @@ -195,12 +189,13 @@ export const ExplorerCircles = () => { return d.parent === focus ? 'inline' : 'none'; }); - // Add the underlined text to the group labelGroup .append('text') .attr('class', 'label') .style('text-anchor', 'middle') .style('text-decoration', 'underline') + .style('font', "11px 'Helvetica Neue', Helvetica, Arial, sans-serif") + .style('text-shadow', '0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff') .text(function (d: any) { if (!d.data.displayName) return ''; let name = d.data.displayName; @@ -208,7 +203,6 @@ export const ExplorerCircles = () => { return name; }); - // Add the downward-pointing tick line to the group labelGroup .append('line') .attr('class', 'label-tick') @@ -300,6 +294,7 @@ export const ExplorerCircles = () => { style={{ margin: 0, marginBottom: 0, + marginTop: 0, textAlign: 'center', borderRadius: '8px 8px 0 0', width: '100vw', @@ -308,11 +303,20 @@ export const ExplorerCircles = () => { boxSizing: 'border-box', position: 'relative', zIndex: 10, + fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", + padding: '10px', + backgroundColor: '#f8f8f8', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + overflow: 'auto', + lineHeight: '1.4', + whiteSpace: 'normal', + wordBreak: 'break-word', + overflowWrap: 'anywhere' }} > {breadcrumb.map((item, index) => ( - {index > 0 && } + {index > 0 && } { color: index === breadcrumb.length - 1 ? '#333' : '#2185d0', fontWeight: index === breadcrumb.length - 1 ? 'bold' : 500, textDecoration: index === breadcrumb.length - 1 ? 'none' : 'underline', + wordBreak: 'break-word', + overflowWrap: 'anywhere', + display: 'inline', + maxWidth: '100%' }} onClick={() => { if (index < breadcrumb.length - 1) { @@ -371,9 +379,20 @@ export const ExplorerCircles = () => { zIndex: 21, }} > - +
    { zIndex: 20, }} > - - + + +
    { const [maxNodeSize, setMaxNodeSize] = useState(0); const { dataLoading, dataTree, getStoreKey, dataStore } = useDataStore(); - // ADDING STATE FOR FILTERING LOGIC const [filterTypeA, setFilterTypeA] = useState(''); const [filterTypeB, setFilterTypeB] = useState(''); - // Separated CRE options and combined options with proper typing const [creOptions, setCreOptions] = useState([]); const [combinedOptions, setCombinedOptions] = useState([]); - // Adding a show all checkbox const [showAll, setShowAll] = useState(true); - // Helper function to get base name from standard ID const getBaseName = (standardId: string): string => { - // Split by ':' and take the first part return standardId.split(':')[0]; }; - // Helper function to create grouped standard ID const getGroupedStandardId = (baseName: string): string => { return `grouped_${baseName}`; }; - //Added helper function for cleaner code organization const getBaseNameFromGrouped = (groupedId: string): string => { return groupedId.replace('grouped_', ''); }; - // Build CRE options separately for better organization and type safety from Data Store useEffect(() => { const creList: DropdownOption[] = Object.values(dataStore) .filter((n) => n.doctype === 'CRE') @@ -74,16 +66,12 @@ export const ExplorerForceGraph = () => { links: [], }; - // Get all the nodes and types const allNodes = Object.values(dataStore); - // Function to collect standards from tree structure function collectStandards(node: any, standards: any[] = []): any[] { - // Added optional chaining for better null safety if (node.doctype && node.doctype.toLowerCase() === 'standard') { standards.push(node); } - //Added Array.isArray check for better safety if (node.links && Array.isArray(node.links)) { node.links.forEach((link: any) => { if (link.document) { @@ -99,7 +87,6 @@ export const ExplorerForceGraph = () => { allStandardNodes = allStandardNodes.concat(collectStandards(rootNode)); }); - // Group standards by base name const groupedStandards = new Map(); allStandardNodes.forEach((node: any) => { const baseName = getBaseName(node.id); @@ -109,7 +96,6 @@ export const ExplorerForceGraph = () => { groupedStandards.get(baseName)!.push(node); }); - // Create mapping for original IDs to grouped IDs const originalToGroupedMap = new Map(); groupedStandards.forEach((nodes, baseName) => { const groupedId = getGroupedStandardId(baseName); @@ -122,7 +108,6 @@ export const ExplorerForceGraph = () => { console.log('Standard IDs from JSON data:', standardNodeIds); console.log('Grouped standards:', Array.from(groupedStandards.keys())); - // Build standard dropdown options with count display for better Ui const standardDropdownOptions: DropdownOption[] = Array.from(groupedStandards.entries()).map( ([baseName, group]) => ({ key: getGroupedStandardId(baseName), @@ -131,14 +116,12 @@ export const ExplorerForceGraph = () => { }) ); - // Helper functions for filtering logic const isAll = (val: string) => val && val.startsWith('all_'); const isGroupedStandard = (val: string) => val && val.startsWith('grouped_'); const getTypeFromAll = (val: string) => val.replace('all_', ''); - // Improved matchesFilter function with better null safety and type checking const matchesFilter = (node: any, filterVal: string): boolean => { - if (!filterVal || filterVal === '') return true; // No filter, show all + if (!filterVal || filterVal === '') return true; if (isAll(filterVal)) { const type = getTypeFromAll(filterVal); @@ -153,37 +136,7 @@ export const ExplorerForceGraph = () => { return node.id === filterVal; }; - // NEW APPROACH: Simplified graph data population - collect all data first, then filter - // This is cleaner and easier to debug than filtering during traversal - const populateGraphData = (node: any) => { - if (node.links && Array.isArray(node.links)) { - node.links.forEach((x: LinkedTreeDocument) => { - if (x.document && !ignoreTypes.includes(x.ltype.toLowerCase())) { - // Use grouped IDs for standard nodes in links - const sourceKey = - node.doctype?.toLowerCase() === 'standard' - ? originalToGroupedMap.get(node.id) || getStoreKey(node) - : getStoreKey(node); - const targetKey = - x.document.doctype?.toLowerCase() === 'standard' - ? originalToGroupedMap.get(x.document.id) || getStoreKey(x.document) - : getStoreKey(x.document); - - gData.links.push({ - source: sourceKey, - target: targetKey, - count: x.ltype === 'Contains' ? 2 : 1, - type: x.ltype, - }); - - populateGraphData(x.document); - } - }); - } - }; - // Build the complete graph first - dataTree.forEach((x) => populateGraphData(x)); // OLD APPROACH: Complex filtering during graph traversal with many nested conditions // This made the code hard to understand and debug @@ -237,14 +190,40 @@ export const ExplorerForceGraph = () => { // }); // } - // NEW APPROACH: Apply filtering after building complete graph for better separation of concerns + + const populateGraphData = (node: any) => { + if (node.links && Array.isArray(node.links)) { + node.links.forEach((x: LinkedTreeDocument) => { + if (x.document && !ignoreTypes.includes(x.ltype.toLowerCase())) { + const sourceKey = + node.doctype?.toLowerCase() === 'standard' + ? originalToGroupedMap.get(node.id) || getStoreKey(node) + : getStoreKey(node); + const targetKey = + x.document.doctype?.toLowerCase() === 'standard' + ? originalToGroupedMap.get(x.document.id) || getStoreKey(x.document) + : getStoreKey(x.document); + + gData.links.push({ + source: sourceKey, + target: targetKey, + count: x.ltype === 'Contains' ? 2 : 1, + type: x.ltype, + }); + + populateGraphData(x.document); + } + }); + } + }; + + dataTree.forEach((x) => populateGraphData(x)); + if (!showAll && (filterTypeA || filterTypeB)) { gData.links = gData.links.filter((link: any) => { - // Get source and target nodes with better error handling let sourceNode = dataStore[link.source]; let targetNode = dataStore[link.target]; - // NEW APPROACH: Better handling of grouped standard nodes with all required properties if (link.source.startsWith('grouped_')) { const baseName = getBaseNameFromGrouped(link.source); sourceNode = { @@ -252,7 +231,7 @@ export const ExplorerForceGraph = () => { doctype: 'standard', displayName: baseName, links: [], - url: '', // Add missing properties for type safety + url: '', name: baseName, }; } @@ -264,15 +243,13 @@ export const ExplorerForceGraph = () => { doctype: 'standard', displayName: baseName, links: [], - url: '', // Add missing properties for type safety + url: '', name: baseName, }; } if (!sourceNode || !targetNode) return false; - // NEW APPROACH: Simplified filtering - show link if any node matches any filter - // This is more permissive and user-friendly than the complex logic above const sourceMatchesA = matchesFilter(sourceNode, filterTypeA); const sourceMatchesB = matchesFilter(sourceNode, filterTypeB); const targetMatchesA = matchesFilter(targetNode, filterTypeA); @@ -282,11 +259,9 @@ export const ExplorerForceGraph = () => { }); } - // Build nodes from filtered links const nodesMap: any = {}; const addNode = function (name: string) { if (!nodesMap[name]) { - // Check if this is a grouped standard node if (name.startsWith('grouped_')) { const baseName = getBaseNameFromGrouped(name); const groupedNodes = groupedStandards.get(baseName) || []; @@ -297,7 +272,7 @@ export const ExplorerForceGraph = () => { size: totalSize, name: baseName, doctype: 'standard', - originalNodes: groupedNodes, // Store original nodes for reference + originalNodes: groupedNodes, }; } else { const storedDoc = dataStore[name]; @@ -319,7 +294,6 @@ export const ExplorerForceGraph = () => { addNode(link.target); }); - // Clean, organized combined options with clear sections and separators const combined: DropdownOption[] = [ { key: 'none_typeB', text: 'None', value: '' }, { key: 'all_standard', text: 'ALL Standards', value: 'all_standard' }, @@ -338,17 +312,15 @@ export const ExplorerForceGraph = () => { setCombinedOptions(combined); - // Added initial value to reduce array - with better error handling setMaxNodeSize(gData.nodes.map((n: any) => n.size).reduce((a: number, b: number) => Math.max(a, b), 0)); setMaxCount(gData.links.map((l: any) => l.count).reduce((a: number, b: number) => Math.max(a, b), 0)); - // Reverse links for proper display gData.links = gData.links.map((l: any) => { return { source: l.target, target: l.source, count: l.count, type: l.type }; }); setGraphData(gData); - }, [ignoreTypes, dataTree, filterTypeA, filterTypeB, showAll, dataStore]); // NEW APPROACH: Removed standardOptions dependency + }, [ignoreTypes, dataTree, filterTypeA, filterTypeB, showAll, dataStore]); const getLinkColor = (ltype: string) => { switch (ltype.toLowerCase()) { @@ -366,9 +338,6 @@ export const ExplorerForceGraph = () => { const getNodeColor = (doctype: string) => { switch (doctype.toLowerCase()) { case 'cre': - // OLD APPROACH: CRE nodes had no color (empty string) which made them hard to see - // return ''; - // NEW APPROACH: Give CRE nodes a visible color for better UI return 'lightblue'; case 'standard': return 'orange'; @@ -394,49 +363,85 @@ export const ExplorerForceGraph = () => { }; return ( -
    +
    - toggleLinks('contains')} - /> - {' | '} - toggleLinks('related')} - /> - {' | '} - toggleLinks('linked to')} - /> - {' | '} - toggleLinks('same')} /> +
    + + | + + | + + | + +
    - setFilterTypeA((data.value ?? '') as string)} + onChange={(e) => setFilterTypeA(e.target.value)} + className="border border-gray-300 rounded px-3 py-2 mr-2" style={{ marginRight: '10px' }} - selection - search - /> - + {creOptions.map((opt) => ( + + ))} + + + | +
    {showAll || filterTypeA || filterTypeB ? ( graphData && ( @@ -452,7 +457,7 @@ export const ExplorerForceGraph = () => { /> ) ) : ( -
    +
    Please select at least one filter to view the graph or check "Show All".
    )} diff --git a/application/frontend/src/pages/GapAnalysis/GapAnalysis.scss b/application/frontend/src/pages/GapAnalysis/GapAnalysis.scss deleted file mode 100644 index d15bf008..00000000 --- a/application/frontend/src/pages/GapAnalysis/GapAnalysis.scss +++ /dev/null @@ -1,17 +0,0 @@ -main#gap-analysis { - padding: 30px; - margin: var(--header-height) 0; - - span.name { - padding: 0 10px; - } -} - -@media (min-width: 0px) and (max-width: 500px) { - main#gap-analysis { - span.name { - width: 85px; - display: inline-block; - } - } -} diff --git a/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx b/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx index 9592cfae..c31cdfac 100644 --- a/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx +++ b/application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx @@ -1,10 +1,7 @@ -import './GapAnalysis.scss'; - import axios from 'axios'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { Button, Dropdown, DropdownItemProps, Icon, Popup, Table } from 'semantic-ui-react'; - +import { ArrowDown, ArrowUp, Share2, Info, Loader2, XCircle, CheckCircle } from 'lucide-react'; import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; import { GA_STRONG_UPPER_LIMIT } from '../../const'; import { useEnvironment } from '../../hooks'; @@ -12,24 +9,26 @@ import { GapAnalysisPathStart } from '../../types'; import { getDocumentDisplayName } from '../../utils'; import { getInternalUrl } from '../../utils/document'; -const GetSegmentText = (segment, segmentID) => { + +const GetSegmentText = (segment: any, segmentID: string) => { let textPart = segment.end; let nextID = segment.end.id; - let arrow = ; + let ArrowIcon = ; if (segmentID !== segment.start.id) { textPart = segment.start; nextID = segment.start.id; - arrow = ; + ArrowIcon = ; } const text = ( <>
    - {arrow}{' '} - + {ArrowIcon}{' '} + {segment.relationship.replace('_', ' ').toLowerCase()} {segment.score > 0 && <> (+{segment.score})} -
    {getDocumentDisplayName(textPart, true)} {textPart.section ?? ''} {textPart.subsection ?? ''}{' '} - {textPart.description ?? ''} +
    + {getDocumentDisplayName(textPart, true)}{' '} + {textPart.section ?? ''} {textPart.subsection ?? ''} {textPart.description ?? ''} ); return { text, nextID }; @@ -37,80 +36,123 @@ const GetSegmentText = (segment, segmentID) => { function useQuery() { const { search } = useLocation(); - return React.useMemo(() => new URLSearchParams(search), [search]); } -const GetStrength = (score) => { +const GetStrength = (score: number) => { if (score == 0) return 'Direct'; if (score <= GA_STRONG_UPPER_LIMIT) return 'Strong'; if (score >= 20) return 'Weak'; return 'Average'; }; -const GetStrengthColor = (score) => { - if (score === 0) return 'darkgreen'; - if (score <= GA_STRONG_UPPER_LIMIT) return '#93C54B'; - if (score >= 20) return 'Red'; - return 'Orange'; +const GetStrengthColor = (score: number) => { + if (score === 0) return 'text-green-700'; + if (score <= GA_STRONG_UPPER_LIMIT) return 'text-lime-600'; + if (score >= 20) return 'text-red-600'; + return 'text-amber-500'; +}; + +type DropdownItemProps = { + key: string; + text: string; + value: string | undefined; +}; + +const Tooltip = ({ trigger, content, wide = false }: { trigger: React.ReactNode; content: React.ReactNode; wide?: boolean }) => { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ref]); + + const contentClasses = `absolute z-50 p-3 bg-white border border-gray-300 rounded-lg shadow-xl text-sm ${wide ? 'w-80' : 'w-64' + } top-full mt-2 left-1/2 transform -translate-x-1/2 transition-opacity duration-300 ${isOpen ? 'opacity-100 visible' : 'opacity-0 invisible' + }`; + + return ( +
    setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + {trigger} +
    +
    {content}
    +
    +
    + ); }; -const GetResultLine = (path, gapAnalysis, key) => { + +const GetResultLine = (path: any, gapAnalysis: Record, key: string) => { let segmentID = gapAnalysis[key].start.id; + const strengthColor = GetStrengthColor(path.score); + + const pathContent = ( + <> + {getDocumentDisplayName(gapAnalysis[key].start, true)} + {path.path.map((segment: any) => { + const { text, nextID } = GetSegmentText(segment, segmentID); + segmentID = nextID; + return text; + })} + + ); + + const scoreContent = ( + <> + Generally: lower is better +
    + {GetStrength(0)} (0): Directly Linked +
    + {GetStrength(GA_STRONG_UPPER_LIMIT)} ($\leq{GA_STRONG_UPPER_LIMIT}$): Closely connected likely to have majority overlap +
    + {GetStrength(6)} (6): Connected likely to have partial overlap +
    + {GetStrength(22)} (22): Weakly connected likely to have small or no overlap +
    + + ); + return ( - ); }; + export const GapAnalysis = () => { - const standardOptionsDefault = [{ key: '', text: '', value: undefined }]; + const standardOptionsDefault: DropdownItemProps[] = [{ key: 'default', text: 'Select Standard', value: undefined }]; const searchParams = useQuery(); const [standardOptions, setStandardOptions] = useState( standardOptionsDefault @@ -125,21 +167,21 @@ export const GapAnalysis = () => { const [loadingGA, setLoadingGA] = useState(false); const [error, setError] = useState(null); const { apiUrl } = useEnvironment(); - const timerIdRef = useRef(); + const timerIdRef = useRef(undefined); useEffect(() => { const fetchData = async () => { const result = await axios.get(`${apiUrl}/standards`); setLoadingStandards(false); setStandardOptions( - standardOptionsDefault.concat(result.data.sort().map((x) => ({ key: x, text: x, value: x }))) + standardOptionsDefault.concat(result.data.sort().map((x: string) => ({ key: x, text: x, value: x }))) ); }; setLoadingStandards(true); fetchData().catch((e) => { setLoadingStandards(false); - setError(e.response.data.message ?? e.message); + setError((e.response?.data?.message as string) ?? e.message); }); }, [setStandardOptions, setLoadingStandards, setError]); @@ -156,22 +198,29 @@ export const GapAnalysis = () => { if (result.data.result) { setLoadingGA(false); setGapAnalysis(result.data.result); - setgaJob(''); + setgaJob(''); // Clears job ID on success to stop polling } }; if (!gaJob) return; fetchData().catch((e) => { setLoadingGA(false); - setError(e.response.data.message ?? e.message); + setError((e.response?.data?.message as string) ?? e.message); + // ⭐ IMPORTANT FIX: Clear the job ID on polling failure to stop the interval + setgaJob(''); }); }; const startPolling = () => { - // Polling every 10 seconds - timerIdRef.current = setInterval(pollingCallback, 10000); + if (timerIdRef.current === undefined) { + timerIdRef.current = setInterval(pollingCallback, 10000) as unknown as number; + } }; + const stopPolling = () => { - clearInterval(timerIdRef.current); + if (timerIdRef.current !== undefined) { + clearInterval(timerIdRef.current); + timerIdRef.current = undefined; + } }; if (gaJob) { @@ -197,122 +246,174 @@ export const GapAnalysis = () => { setGapAnalysis(result.data.result); } else if (result.data.job_id) { setgaJob(result.data.job_id); + // Note: loadingGA remains true here, expecting polling to clear it. + } else { + // ⭐ OPTIONAL FIX: Handle unexpected API response that has neither result nor job_id. + console.error("API response missing result or job_id."); + setError("Analysis request failed to start correctly."); + setLoadingGA(false); } }; if (!BaseStandard || !CompareStandard || BaseStandard === CompareStandard) return; setGapAnalysis(undefined); + setError(null); // Clear any previous error before starting new analysis setLoadingGA(true); fetchData().catch((e) => { setLoadingGA(false); - setError(e.response.data.message ?? e.message); + setError((e.response?.data?.message as string) ?? e.message); }); }, [BaseStandard, CompareStandard, setGapAnalysis, setLoadingGA, setError]); + // ... rest of the component is unchanged const getWeakLinks = useCallback( - async (key) => { + async (key: string) => { if (!gapAnalysis) return; - const result = await axios.get( - `${apiUrl}/map_analysis_weak_links?standard=${BaseStandard}&standard=${CompareStandard}&key=${key}` - ); - if (result.data.result) { - gapAnalysis[key].weakLinks = result.data.result.paths; - setGapAnalysis(undefined); //THIS HAS TO BE THE WRONG WAY OF DOING THIS - setGapAnalysis(gapAnalysis); + try { + const result = await axios.get( + `${apiUrl}/map_analysis_weak_links?standard=${BaseStandard}&standard=${CompareStandard}&key=${key}` + ); + if (result.data.result) { + setGapAnalysis((prevGapAnalysis) => { + if (!prevGapAnalysis) return undefined; + const newGapAnalysis = { ...prevGapAnalysis }; + newGapAnalysis[key] = { + ...newGapAnalysis[key], + weakLinks: result.data.result.paths, + }; + return newGapAnalysis; + }); + } + } catch (e: any) { + setError(e.response?.data?.message ?? e.message); } }, - [gapAnalysis, setGapAnalysis] + [gapAnalysis, BaseStandard, CompareStandard, apiUrl] ); + const StandardDropdown = ({ + placeholder, + options, + value, + onChange, + disabled = false + }: { + placeholder: string; + options: DropdownItemProps[] | undefined; + value: string | undefined; + onChange: (value: string | undefined) => void; + disabled?: boolean; + }) => { + return ( + + ); + }; + return ( -
    -

    Map Analysis

    +
    +

    Map Analysis

    - - - - - Base: - setBaseStandard(value?.toString())} - value={BaseStandard} - /> - - - Compare: - setCompareStandard(value?.toString())} - value={CompareStandard} - /> - {gapAnalysis && ( -
    - -
    - )} -
    -
    -
    - - {gapAnalysis && ( - <> - {Object.keys(gapAnalysis) - .sort((a, b) => - getDocumentDisplayName(gapAnalysis[a].start, true).localeCompare( - getDocumentDisplayName(gapAnalysis[b].start, true) - ) +
    +
    +
    + Base: + +
    +
    + Compare: + + {gapAnalysis && ( +
    + +
    + )} +
    +
    + +
    + {gapAnalysis && + Object.keys(gapAnalysis) + .sort((a, b) => + getDocumentDisplayName(gapAnalysis[a].start, true).localeCompare( + getDocumentDisplayName(gapAnalysis[b].start, true) ) - .map((key) => ( - - - -

    - {getDocumentDisplayName(gapAnalysis[key].start, true)} -

    -
    -
    - - {Object.values(gapAnalysis[key].paths) + ) + .map((key) => ( +
    + +
    + {Object.values(gapAnalysis[key].paths) + .sort((a, b) => a.score - b.score) + .map((path) => GetResultLine(path, gapAnalysis, key))} + + {gapAnalysis[key].weakLinks && + Object.values(gapAnalysis[key].weakLinks) .sort((a, b) => a.score - b.score) .map((path) => GetResultLine(path, gapAnalysis, key))} - {gapAnalysis[key].weakLinks && - Object.values(gapAnalysis[key].weakLinks) - .sort((a, b) => a.score - b.score) - .map((path) => GetResultLine(path, gapAnalysis, key))} - {gapAnalysis[key].extra > 0 && !gapAnalysis[key].weakLinks && ( - - )} - {Object.keys(gapAnalysis[key].paths).length === 0 && gapAnalysis[key].extra === 0 && ( - No links Found - )} - - - ))} - - )} - -
    + + {gapAnalysis[key].extra > 0 && !gapAnalysis[key].weakLinks && ( + + )} + {Object.keys(gapAnalysis[key].paths).length === 0 && gapAnalysis[key].extra === 0 && ( + No links Found + )} +
    +
    + ))} +
    +
    ); -}; +}; \ No newline at end of file diff --git a/application/frontend/src/pages/MembershipRequired/MembershipRequired.scss b/application/frontend/src/pages/MembershipRequired/MembershipRequired.scss deleted file mode 100644 index 17e47276..00000000 --- a/application/frontend/src/pages/MembershipRequired/MembershipRequired.scss +++ /dev/null @@ -1,8 +0,0 @@ -.membership-required { - margin-top: 20vh; - text-align: center; - - p { - font-weight: bold; - } -} \ No newline at end of file diff --git a/application/frontend/src/pages/MembershipRequired/MembershipRequired.tsx b/application/frontend/src/pages/MembershipRequired/MembershipRequired.tsx index a4ccb6db..90d6debb 100644 --- a/application/frontend/src/pages/MembershipRequired/MembershipRequired.tsx +++ b/application/frontend/src/pages/MembershipRequired/MembershipRequired.tsx @@ -1,18 +1,26 @@ -import './MembershipRequired.scss'; - import React from 'react'; -import { Button, Header } from 'semantic-ui-react'; + export const MembershipRequired = () => { return ( -
    -
    +
    +

    OWASP Membership Required -

    -

    A OWASP Membership account is needed to login

    - +
    ); -}; +}; \ No newline at end of file diff --git a/application/frontend/src/pages/Search/Search.tsx b/application/frontend/src/pages/Search/Search.tsx index bc0c8be0..465a1b3d 100644 --- a/application/frontend/src/pages/Search/Search.tsx +++ b/application/frontend/src/pages/Search/Search.tsx @@ -1,5 +1,3 @@ -import './search.scss'; - import { ArrowDown, Eye, Link2, MessageSquare, Network, Search } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { Link, useHistory } from 'react-router-dom'; @@ -11,7 +9,6 @@ export const SearchPage = () => { const { toast } = useToast(); const [isArrowVisible, setIsArrowVisible] = useState(true); - // const [loading, setLoading] = useState(false); //Search Functionality const history = useHistory(); @@ -36,8 +33,6 @@ export const SearchPage = () => { }; }, []); - // The handleSignOut function is no longer needed. - const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (search.term.trim()) { @@ -70,82 +65,80 @@ export const SearchPage = () => { } }; - // useEffect(() => { - // // Simulate data fetching or API call - // const timer = setTimeout(() => { - // setLoading(false); // ✅ stop loading after 2s - // }, 1000); - - // return () => clearTimeout(timer); // cleanup - // }, []); - - // if (loading) { - // return ( - //
    - //
    - //
    - //

    Loading...

    - //
    - //
    - // ); - // } - return ( -
    +
    + {/* Bouncing Down Arrow */} {isArrowVisible && ( -
    +
    )} -
    -
    -
    -
    -
    -
    + {/* Hero Section */} +
    + {/* Background Effects */} +
    +
    +
    +
    +
    -
    -
    -
    - OpenCRE - Open Common Requirement Enumeration + {/* Content */} +
    + {/* Logo Container */} +
    +
    + OpenCRE - Open Common Requirement Enumeration
    -

    - All security standards and guidelines unified +

    + All security standards and guidelines unified

    -
    -

    + {/* Description */} +

    +

    OpenCRE is an interactive content linking platform for uniting security standards and guidelines. It offers easy navigation between documents, requirements and tools, making it easier for developers and security professionals to find the resources they need.

    -
    -