Skip to content

Commit 630abb1

Browse files
UI Refinements
- Improve no items, error display. - Add scroll to top on page navigations - Improve purpose and element create modals
1 parent c3648bc commit 630abb1

14 files changed

Lines changed: 481 additions & 110 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import { afterEach, describe, expect, it, vi } from 'vitest'
20+
import { createElement } from '../features/catalog/api/catalogApi'
21+
22+
const fetchMock = vi.fn()
23+
24+
afterEach(() => {
25+
fetchMock.mockReset()
26+
vi.unstubAllGlobals()
27+
})
28+
29+
describe('createElement', () => {
30+
it('returns the created element from an array response', async () => {
31+
const element = {
32+
elementId: '82017ca0-a120-4bf3-ac1c-ea76630d86cf',
33+
name: 'Test 2',
34+
namespace: 'default',
35+
type: 'basic' as const,
36+
version: 'v1',
37+
createdTime: 1785447718074,
38+
}
39+
vi.stubGlobal('fetch', fetchMock)
40+
fetchMock.mockResolvedValue({
41+
ok: true,
42+
status: 200,
43+
json: async () => [{ status: 'SUCCESS', element }],
44+
})
45+
46+
await expect(createElement({ name: 'Test 2', type: 'basic' })).resolves.toEqual(element)
47+
})
48+
49+
it('preserves support for the wrapped bulk response', async () => {
50+
const element = {
51+
elementId: 'element-1',
52+
name: 'Email',
53+
namespace: 'default',
54+
type: 'basic' as const,
55+
version: 'v1',
56+
createdTime: 1785447718074,
57+
}
58+
vi.stubGlobal('fetch', fetchMock)
59+
fetchMock.mockResolvedValue({
60+
ok: true,
61+
status: 200,
62+
json: async () => ({
63+
results: [{ index: 0, status: 'SUCCESS', data: element }],
64+
}),
65+
})
66+
67+
await expect(createElement({ name: 'Email', type: 'basic' })).resolves.toEqual(element)
68+
})
69+
70+
it('surfaces a string failure returned for an item', async () => {
71+
vi.stubGlobal('fetch', fetchMock)
72+
fetchMock.mockResolvedValue({
73+
ok: true,
74+
status: 200,
75+
json: async () => [{ status: 'FAILED', error: 'Element already exists' }],
76+
})
77+
78+
await expect(createElement({ name: 'Email', type: 'basic' })).rejects.toThrow(
79+
'Element already exists',
80+
)
81+
})
82+
})

portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ describe('ConsentRegistryPage', () => {
164164

165165
expect(await screen.findByRole('heading', { name: 'All Consents' })).toBeInTheDocument()
166166
expect(await screen.findByText('Unable to load consents right now.')).toBeInTheDocument()
167-
expect(screen.queryByRole('table', { name: 'Consent registry table' })).not.toBeInTheDocument()
167+
expect(screen.getByRole('table', { name: 'Consent registry table' })).toBeInTheDocument()
168+
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
168169
})
169170

170171
it('renders consent rows in API order without sorting the current page locally', async () => {
@@ -223,6 +224,7 @@ describe('ConsentRegistryPage', () => {
223224
expect(
224225
await screen.findByText('No consents found for the selected filters.'),
225226
).toBeInTheDocument()
227+
expect(screen.getByRole('table', { name: 'Consent registry table' })).toBeInTheDocument()
226228
})
227229

228230
it('shows the error state when a consent response has an unsupported status', async () => {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
20+
import { MemoryRouter, useNavigate } from 'react-router-dom'
21+
import { afterEach, describe, expect, it, vi } from 'vitest'
22+
import RouteScrollManager from '../components/layout/main-layout/RouteScrollManager'
23+
24+
function NavigationControls(): React.JSX.Element {
25+
const navigate = useNavigate()
26+
27+
return (
28+
<>
29+
<button type="button" onClick={() => navigate('/first?page=2')}>
30+
Change query
31+
</button>
32+
<button type="button" onClick={() => navigate('/second')}>
33+
Change page
34+
</button>
35+
<button type="button" onClick={() => navigate('/second#target')}>
36+
Open hash
37+
</button>
38+
<button type="button" onClick={() => navigate(-1)}>
39+
Go back
40+
</button>
41+
<div id="target">Target</div>
42+
</>
43+
)
44+
}
45+
46+
function renderManager(initialEntries = ['/first'], initialIndex = 0): void {
47+
render(
48+
<MemoryRouter initialEntries={initialEntries} initialIndex={initialIndex}>
49+
<div data-testid="scroll-container">
50+
<RouteScrollManager />
51+
<NavigationControls />
52+
</div>
53+
</MemoryRouter>,
54+
)
55+
}
56+
57+
afterEach(() => {
58+
cleanup()
59+
vi.restoreAllMocks()
60+
})
61+
62+
describe('RouteScrollManager', () => {
63+
it('scrolls to the top for a new pathname but not a query-only update', async () => {
64+
renderManager()
65+
const scrollContainer = screen.getByTestId('scroll-container')
66+
const scrollTo = vi.fn()
67+
scrollContainer.scrollTo = scrollTo
68+
69+
fireEvent.click(screen.getByRole('button', { name: 'Change query' }))
70+
expect(scrollTo).not.toHaveBeenCalled()
71+
72+
fireEvent.click(screen.getByRole('button', { name: 'Change page' }))
73+
74+
await waitFor(() => {
75+
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' })
76+
})
77+
})
78+
79+
it('scrolls hash navigation to its target instead of the page top', async () => {
80+
const scrollIntoView = vi.fn()
81+
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView
82+
HTMLElement.prototype.scrollIntoView = scrollIntoView
83+
renderManager()
84+
const scrollContainer = screen.getByTestId('scroll-container')
85+
const scrollTo = vi.fn()
86+
scrollContainer.scrollTo = scrollTo
87+
88+
fireEvent.click(screen.getByRole('button', { name: 'Open hash' }))
89+
90+
await waitFor(() => {
91+
expect(scrollIntoView).toHaveBeenCalled()
92+
})
93+
expect(scrollTo).not.toHaveBeenCalled()
94+
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
95+
})
96+
97+
it('leaves back navigation to browser scroll restoration', async () => {
98+
renderManager(['/first', '/second'], 1)
99+
const scrollContainer = screen.getByTestId('scroll-container')
100+
const scrollTo = vi.fn()
101+
scrollContainer.scrollTo = scrollTo
102+
103+
fireEvent.click(screen.getByRole('button', { name: 'Go back' }))
104+
105+
await waitFor(() => {
106+
expect(scrollTo).not.toHaveBeenCalled()
107+
})
108+
})
109+
})

portal/frontend/src/components/layout/main-layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useState } from 'react'
2121
import { useTranslation } from 'react-i18next'
2222
import { Outlet } from 'react-router-dom'
2323
import AppSidebar from '../sidebar/AppSidebar'
24+
import RouteScrollManager from './RouteScrollManager'
2425
import UserProfileMenu from './UserProfileMenu'
2526

2627
function MainLayout(): React.JSX.Element {
@@ -66,6 +67,7 @@ function MainLayout(): React.JSX.Element {
6667
</AppShell.Sidebar>
6768

6869
<AppShell.Main>
70+
<RouteScrollManager />
6971
<Box
7072
sx={{
7173
width: '100%',
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import { useEffect, useRef } from 'react'
20+
import { useLocation, useNavigationType } from 'react-router-dom'
21+
22+
function decodeHash(hash: string): string {
23+
const value = hash.slice(1)
24+
25+
try {
26+
return decodeURIComponent(value)
27+
} catch {
28+
return value
29+
}
30+
}
31+
32+
function RouteScrollManager(): React.JSX.Element {
33+
const { pathname, hash } = useLocation()
34+
const navigationType = useNavigationType()
35+
const previousPathname = useRef(pathname)
36+
const markerRef = useRef<HTMLSpanElement>(null)
37+
38+
useEffect(() => {
39+
const pathChanged = previousPathname.current !== pathname
40+
previousPathname.current = pathname
41+
42+
if (hash) {
43+
document.getElementById(decodeHash(hash))?.scrollIntoView()
44+
return
45+
}
46+
47+
if (pathChanged && navigationType !== 'POP') {
48+
markerRef.current?.parentElement?.scrollTo({
49+
top: 0,
50+
left: 0,
51+
behavior: 'auto',
52+
})
53+
}
54+
}, [hash, navigationType, pathname])
55+
56+
return <span ref={markerRef} aria-hidden="true" hidden />
57+
}
58+
59+
export default RouteScrollManager

portal/frontend/src/features/catalog/ElementListPage.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
Tooltip,
2626
Typography,
2727
} from '@wso2/oxygen-ui'
28-
import { ListFilter, Plus, Search } from '@wso2/oxygen-ui-icons-react'
28+
import { CircleSlash, ListFilter, Plus, RefreshCw, Search } from '@wso2/oxygen-ui-icons-react'
2929
import { useMemo, useState } from 'react'
3030
import { useTranslation } from 'react-i18next'
3131
import { useNavigate, useSearchParams } from 'react-router-dom'
@@ -370,21 +370,6 @@ function ElementListPage(): React.JSX.Element {
370370
</Stack>
371371
) : null}
372372

373-
{query.isError ? (
374-
<Paper variant="outlined" sx={{ p: 2, borderColor: 'error.main' }}>
375-
<Stack
376-
direction={{ xs: 'column', sm: 'row' }}
377-
justifyContent="space-between"
378-
spacing={1}
379-
>
380-
<Typography color="error.main">{t('catalog.elements.loadFailed')}</Typography>
381-
<Button size="small" onClick={() => query.refetch()}>
382-
{t('catalog.actions.retry')}
383-
</Button>
384-
</Stack>
385-
</Paper>
386-
) : null}
387-
388373
<TableContainer component={Paper} elevation={1}>
389374
<Box
390375
sx={{
@@ -501,6 +486,24 @@ function ElementListPage(): React.JSX.Element {
501486
</TableCell>
502487
</TableRow>
503488
))}
489+
{query.isError ? (
490+
<TableRow>
491+
<TableCell colSpan={6} align="center" sx={{ py: 8 }}>
492+
<Stack spacing={1} alignItems="center">
493+
<CircleSlash size={28} aria-hidden="true" />
494+
<Typography fontWeight={500}>{t('catalog.elements.loadFailed')}</Typography>
495+
<Button
496+
size="small"
497+
variant="outlined"
498+
startIcon={<RefreshCw size={16} />}
499+
onClick={() => query.refetch()}
500+
>
501+
{t('catalog.actions.retry')}
502+
</Button>
503+
</Stack>
504+
</TableCell>
505+
</TableRow>
506+
) : null}
504507
{!query.isPending && !query.isError && rows.length === 0 ? (
505508
<TableRow>
506509
<TableCell colSpan={6} align="center" sx={{ py: 8 }}>

portal/frontend/src/features/catalog/PurposeListPage.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
ToggleButtonGroup,
2828
Typography,
2929
} from '@wso2/oxygen-ui'
30-
import { ListFilter, Plus, Search } from '@wso2/oxygen-ui-icons-react'
30+
import { CircleSlash, ListFilter, Plus, RefreshCw, Search } from '@wso2/oxygen-ui-icons-react'
3131
import { useMemo, useState } from 'react'
3232
import { useTranslation } from 'react-i18next'
3333
import { useNavigate, useSearchParams } from 'react-router-dom'
@@ -475,21 +475,6 @@ function PurposeListPage(): React.JSX.Element {
475475
) : null}
476476
</Stack>
477477

478-
{query.isError ? (
479-
<Paper variant="outlined" sx={{ p: 2, borderColor: 'error.main' }}>
480-
<Stack
481-
direction={{ xs: 'column', sm: 'row' }}
482-
justifyContent="space-between"
483-
spacing={1}
484-
>
485-
<Typography color="error.main">{t('catalog.purposes.loadFailed')}</Typography>
486-
<Button size="small" onClick={() => query.refetch()}>
487-
{t('catalog.actions.retry')}
488-
</Button>
489-
</Stack>
490-
</Paper>
491-
) : null}
492-
493478
<TableContainer component={Paper} elevation={1}>
494479
<Box
495480
sx={{
@@ -610,6 +595,24 @@ function PurposeListPage(): React.JSX.Element {
610595
</TableCell>
611596
</TableRow>
612597
))}
598+
{query.isError ? (
599+
<TableRow>
600+
<TableCell colSpan={5} align="center" sx={{ py: 8 }}>
601+
<Stack spacing={1} alignItems="center">
602+
<CircleSlash size={28} aria-hidden="true" />
603+
<Typography fontWeight={500}>{t('catalog.purposes.loadFailed')}</Typography>
604+
<Button
605+
size="small"
606+
variant="outlined"
607+
startIcon={<RefreshCw size={16} />}
608+
onClick={() => query.refetch()}
609+
>
610+
{t('catalog.actions.retry')}
611+
</Button>
612+
</Stack>
613+
</TableCell>
614+
</TableRow>
615+
) : null}
613616
{!query.isPending && !query.isError && rows.length === 0 ? (
614617
<TableRow>
615618
<TableCell colSpan={5} align="center" sx={{ py: 8 }}>

0 commit comments

Comments
 (0)