-
+
+
+ }>
+ } />
+ } />
+
+
);
-};
+}
+
+function SubrouteGuard(props: AppWithDataProps): React.ReactElement {
+ const subroute = useSubroute();
+ const subrouteWithLeadingSlash = '/' + subroute;
+
+ switch (subrouteWithLeadingSlash) {
+ case APP_DASHBOARD_BASENAME:
+ return
;
+
+ case ORG_LIBRARY_BASENAME:
+ return
;
+ }
+}
diff --git a/frontend/dashboard/components/NewApplicationForm/NewApplicationForm.tsx b/frontend/dashboard/components/NewApplicationForm/NewApplicationForm.tsx
index 9d4c2f2feba..82350e4947e 100644
--- a/frontend/dashboard/components/NewApplicationForm/NewApplicationForm.tsx
+++ b/frontend/dashboard/components/NewApplicationForm/NewApplicationForm.tsx
@@ -7,7 +7,7 @@ import { RepoNameInput } from '../RepoNameInput';
import { type User } from 'app-shared/types/Repository';
import { type Organization } from 'app-shared/types/Organization';
import { useSelectedContext } from '../../hooks/useSelectedContext';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { type NewAppForm } from '../../types/NewAppForm';
import { useCreateAppFormValidation } from './hooks/useCreateAppFormValidation';
import { Link } from 'react-router-dom';
diff --git a/frontend/dashboard/context/HeaderContext/HeaderContext.tsx b/frontend/dashboard/context/HeaderContext/HeaderContext.tsx
index d16d8a1cd85..7c11b581160 100644
--- a/frontend/dashboard/context/HeaderContext/HeaderContext.tsx
+++ b/frontend/dashboard/context/HeaderContext/HeaderContext.tsx
@@ -2,12 +2,6 @@ import { createContext } from 'react';
import { type Organization } from 'app-shared/types/Organization';
import { type User } from 'app-shared/types/Repository';
-export enum SelectedContextType {
- All = 'all',
- Self = 'self',
- None = 'none',
-}
-
export type HeaderContextType = {
selectableOrgs?: Organization[];
user: User;
diff --git a/frontend/dashboard/context/HeaderContext/index.ts b/frontend/dashboard/context/HeaderContext/index.ts
index ec5587f9307..bb7f609e6e4 100644
--- a/frontend/dashboard/context/HeaderContext/index.ts
+++ b/frontend/dashboard/context/HeaderContext/index.ts
@@ -1 +1 @@
-export { HeaderContext, type HeaderContextType, SelectedContextType } from './HeaderContext';
+export { HeaderContext, type HeaderContextType } from './HeaderContext';
diff --git a/frontend/dashboard/dashboardTestUtils.tsx b/frontend/dashboard/dashboardTestUtils.tsx
index 5000df36425..ec2654a73cf 100644
--- a/frontend/dashboard/dashboardTestUtils.tsx
+++ b/frontend/dashboard/dashboardTestUtils.tsx
@@ -1,6 +1,7 @@
import type { QueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import React from 'react';
+import type { MemoryRouterProps } from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
import type {
ServicesContextProps,
@@ -14,12 +15,13 @@ export type MockServicesContextWrapperProps = {
children: ReactNode;
customServices?: Partial
;
client?: QueryClient;
-};
+} & Pick;
export const MockServicesContextWrapper = ({
children,
customServices,
client = createQueryClientMock(),
+ initialEntries,
}: MockServicesContextWrapperProps) => {
const queries: ServicesContextProviderProps = {
...queriesMock,
@@ -29,7 +31,7 @@ export const MockServicesContextWrapper = ({
};
return (
-
+
{children}
);
diff --git a/frontend/dashboard/enums/HeaderMenuItemKey.ts b/frontend/dashboard/enums/HeaderMenuItemKey.ts
new file mode 100644
index 00000000000..ac1a76fcef2
--- /dev/null
+++ b/frontend/dashboard/enums/HeaderMenuItemKey.ts
@@ -0,0 +1,4 @@
+export enum HeaderMenuItemKey {
+ OrgLibrary = 'org-library',
+ AppDashboard = 'app-dashboard',
+}
diff --git a/frontend/dashboard/enums/SelectedContextType.ts b/frontend/dashboard/enums/SelectedContextType.ts
new file mode 100644
index 00000000000..dbe1e9c483c
--- /dev/null
+++ b/frontend/dashboard/enums/SelectedContextType.ts
@@ -0,0 +1,5 @@
+export enum SelectedContextType {
+ All = 'all',
+ Self = 'self',
+ None = 'none',
+}
diff --git a/frontend/dashboard/enums/Subroute.ts b/frontend/dashboard/enums/Subroute.ts
new file mode 100644
index 00000000000..bc3e1c362dd
--- /dev/null
+++ b/frontend/dashboard/enums/Subroute.ts
@@ -0,0 +1,6 @@
+import { APP_DASHBOARD_BASENAME, ORG_LIBRARY_BASENAME } from 'app-shared/constants';
+
+export enum Subroute {
+ AppDashboard = APP_DASHBOARD_BASENAME,
+ OrgLibrary = ORG_LIBRARY_BASENAME,
+}
diff --git a/frontend/dashboard/hooks/guards/useContextRedirectionGuard.ts b/frontend/dashboard/hooks/guards/useContextRedirectionGuard.ts
index c9ffccefc75..609e105da5f 100644
--- a/frontend/dashboard/hooks/guards/useContextRedirectionGuard.ts
+++ b/frontend/dashboard/hooks/guards/useContextRedirectionGuard.ts
@@ -3,9 +3,10 @@ import { useEffect, useState } from 'react';
import { useSelectedContext } from '../useSelectedContext';
import type { NavigateFunction } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { typedSessionStorage } from '@studio/pure-functions';
import { userHasAccessToSelectedContext } from 'dashboard/utils/userUtils';
+import { useSubroute } from '../useSubRoute';
export type UseRedirectionGuardResult = {
isRedirectionComplete: boolean;
@@ -14,18 +15,23 @@ export type UseRedirectionGuardResult = {
export const useContextRedirectionGuard = (
organizations: Organization[],
): UseRedirectionGuardResult => {
- const selectedContext = useSelectedContext();
+ const selectedContextType = useSelectedContext();
+ const subroute = useSubroute();
const navigate = useNavigate();
const [isContextRedirectionComplete, setIsContextRedirectionComplete] = useState(false);
- if (selectedContext !== SelectedContextType.None) {
- typedSessionStorage.setItem('dashboard::selectedContext', selectedContext);
+ if (selectedContextType !== SelectedContextType.None) {
+ typedSessionStorage.setItem('dashboard::selectedContext', selectedContextType);
}
useEffect(() => {
- handleContextRedirection(selectedContext, organizations, navigate);
+ const dashboardRoute: DashboardRoute = {
+ selectedContextType,
+ subroute,
+ };
+ handleContextRedirection(dashboardRoute, organizations, navigate);
setIsContextRedirectionComplete(true);
- }, [selectedContext, organizations, navigate]);
+ }, [selectedContextType, organizations, navigate, subroute]);
return {
isRedirectionComplete: isContextRedirectionComplete,
@@ -33,43 +39,69 @@ export const useContextRedirectionGuard = (
};
const handleContextRedirection = (
- currentContext: SelectedContextType | string,
+ dashboardRoute: DashboardRoute,
organizations: Organization[],
navigate: NavigateFunction,
): void => {
- const targetContext = getTargetContext(currentContext);
-
- if (!hasAccessToContext(targetContext, organizations)) {
- navigateToContext(SelectedContextType.Self, navigate);
- return;
+ const { selectedContextType, subroute } = dashboardRoute;
+ if (!hasAccessToTargetContext(selectedContextType, organizations)) {
+ navigateToSelf(subroute, navigate);
+ } else {
+ navigateToDifferentContext(dashboardRoute, navigate);
}
-
- if (targetContext === currentContext) return;
- navigateToContext(targetContext, navigate);
};
-const getTargetContext = (
- currentContext: SelectedContextType | string,
-): SelectedContextType | string => {
- if (currentContext === SelectedContextType.None) {
- return typedSessionStorage.getItem('dashboard::selectedContext') || SelectedContextType.Self;
- }
- return currentContext;
-};
+const hasAccessToTargetContext = (
+ selectedContextType: string,
+ organizations: Organization[],
+): boolean => hasAccessToContext(getTargetContext(selectedContextType), organizations);
const hasAccessToContext = (
targetContext: SelectedContextType | string,
organizations: Organization[],
-): boolean => {
- return (
- organizations &&
- userHasAccessToSelectedContext({ selectedContext: targetContext, orgs: organizations })
- );
+): boolean =>
+ organizations &&
+ userHasAccessToSelectedContext({ selectedContext: targetContext, orgs: organizations });
+
+const getTargetContext = (
+ currentContext: SelectedContextType | string,
+): SelectedContextType | string =>
+ currentContext === SelectedContextType.None ? getSelectedContextFromStorage() : currentContext;
+
+const getSelectedContextFromStorage = (): string => {
+ return typedSessionStorage.getItem('dashboard::selectedContext') || SelectedContextType.Self;
+};
+
+const navigateToSelf = (subroute: string, navigate: NavigateFunction): void => {
+ const dashboardRoute: DashboardRoute = {
+ selectedContextType: SelectedContextType.Self,
+ subroute,
+ };
+ navigateToContext(dashboardRoute, navigate);
};
const navigateToContext = (
- targetContext: SelectedContextType | string,
+ { subroute, selectedContextType }: DashboardRoute,
+ navigate: NavigateFunction,
+): void => navigate(subroute + '/' + selectedContextType + location.search, { replace: true });
+
+const navigateToDifferentContext = (
+ { subroute, selectedContextType }: DashboardRoute,
navigate: NavigateFunction,
): void => {
- navigate(targetContext + location.search, { replace: true });
+ if (isTargetContextDifferent(selectedContextType)) {
+ const dashboardRoute: DashboardRoute = {
+ selectedContextType: getTargetContext(selectedContextType),
+ subroute,
+ };
+ navigateToContext(dashboardRoute, navigate);
+ }
+};
+
+const isTargetContextDifferent = (selectedContextType: string): boolean =>
+ getTargetContext(selectedContextType) !== selectedContextType;
+
+type DashboardRoute = {
+ selectedContextType: string;
+ subroute: string;
};
diff --git a/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.test.tsx b/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.test.tsx
index 6ee0bd2a639..aa428165c26 100644
--- a/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.test.tsx
+++ b/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.test.tsx
@@ -3,7 +3,7 @@ import { usePageHeaderTitle } from './usePageHeaderTitle';
import { HeaderContext, type HeaderContextType } from 'dashboard/context/HeaderContext';
import { useSelectedContext } from 'dashboard/hooks/useSelectedContext';
import { headerContextValueMock } from 'dashboard/testing/headerContextMock';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { mockOrg1 } from 'dashboard/testing/organizationMock';
import { renderHookWithProviders } from 'dashboard/testing/mocks';
diff --git a/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.tsx b/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.tsx
index b46a181a5de..f725a381878 100644
--- a/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.tsx
+++ b/frontend/dashboard/hooks/usePageHeaderTitle/usePageHeaderTitle.tsx
@@ -1,5 +1,6 @@
import { useContext } from 'react';
-import { HeaderContext, SelectedContextType } from 'dashboard/context/HeaderContext';
+import { HeaderContext } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { getOrgNameByUsername } from 'dashboard/utils/userUtils';
import { useSelectedContext } from '../useSelectedContext';
diff --git a/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.test.tsx b/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.test.tsx
index b795c8202c3..ad3e20984d3 100644
--- a/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.test.tsx
+++ b/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.test.tsx
@@ -1,10 +1,7 @@
import React from 'react';
import { useProfileMenuTriggerButtonText } from './useProfileMenuTriggerButtonText';
-import {
- HeaderContext,
- type HeaderContextType,
- SelectedContextType,
-} from 'dashboard/context/HeaderContext';
+import { HeaderContext, type HeaderContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { useSelectedContext } from '../useSelectedContext';
import { userMock } from 'dashboard/testing/userMock';
import { headerContextValueMock } from 'dashboard/testing/headerContextMock';
diff --git a/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.tsx b/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.tsx
index 6fb72c909fd..e56533b3882 100644
--- a/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.tsx
+++ b/frontend/dashboard/hooks/useProfileMenuTriggerButtonText/useProfileMenuTriggerButtonText.tsx
@@ -1,5 +1,6 @@
import { useContext } from 'react';
-import { HeaderContext, SelectedContextType } from 'dashboard/context/HeaderContext';
+import { HeaderContext } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { getOrgNameByUsername } from 'dashboard/utils/userUtils';
import { useTranslation } from 'react-i18next';
import { useSelectedContext } from '../useSelectedContext';
diff --git a/frontend/dashboard/hooks/useSelectedContext/useSelectedContext.ts b/frontend/dashboard/hooks/useSelectedContext/useSelectedContext.ts
index adfc1be61d6..c4f596840dd 100644
--- a/frontend/dashboard/hooks/useSelectedContext/useSelectedContext.ts
+++ b/frontend/dashboard/hooks/useSelectedContext/useSelectedContext.ts
@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
export const useSelectedContext = () => {
const { selectedContext = SelectedContextType.None } = useParams();
diff --git a/frontend/dashboard/hooks/useSubRoute/index.ts b/frontend/dashboard/hooks/useSubRoute/index.ts
new file mode 100644
index 00000000000..4ca65a751db
--- /dev/null
+++ b/frontend/dashboard/hooks/useSubRoute/index.ts
@@ -0,0 +1 @@
+export * from './useSubroute';
diff --git a/frontend/dashboard/hooks/useSubRoute/useSubroute.ts b/frontend/dashboard/hooks/useSubRoute/useSubroute.ts
new file mode 100644
index 00000000000..c742e8cf65a
--- /dev/null
+++ b/frontend/dashboard/hooks/useSubRoute/useSubroute.ts
@@ -0,0 +1,10 @@
+import { useParams } from 'react-router-dom';
+import { APP_DASHBOARD_BASENAME } from 'app-shared/constants';
+import { StringUtils } from '@studio/pure-functions';
+
+export function useSubroute(): string {
+ const { subroute = defaultSubroute } = useParams();
+ return subroute;
+}
+
+const defaultSubroute = StringUtils.removeLeadingSlash(APP_DASHBOARD_BASENAME);
diff --git a/frontend/dashboard/pages/CreateService/CreateService.test.tsx b/frontend/dashboard/pages/CreateService/CreateService.test.tsx
index 5c8328adf13..2dbd88cdc4f 100644
--- a/frontend/dashboard/pages/CreateService/CreateService.test.tsx
+++ b/frontend/dashboard/pages/CreateService/CreateService.test.tsx
@@ -9,8 +9,8 @@ import { textMock } from '@studio/testing/mocks/i18nMock';
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
import { repository, user as userMock } from 'app-shared/mocks/mocks';
import { useParams } from 'react-router-dom';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
-import { DASHBOARD_ROOT_ROUTE } from 'app-shared/constants';
+import { Subroute } from '../../enums/Subroute';
+import { SelectedContextType } from '../../enums/SelectedContextType';
const orgMock: Organization = {
avatar_url: '',
@@ -72,6 +72,9 @@ describe('CreateService', () => {
it('should show error messages when clicking create and no owner or name is filled in', async () => {
const user = userEvent.setup();
+ const selectedContext = SelectedContextType.None;
+ const subroute = Subroute.AppDashboard;
+ (useParams as jest.Mock).mockReturnValue({ selectedContext, subroute });
renderWithMockServices({ user: { ...mockUser, login: '' } });
const createBtn: HTMLElement = screen.getByRole('button', {
@@ -297,39 +300,42 @@ describe('CreateService', () => {
expect(windowLocationAssignMock).toHaveBeenCalled();
});
- it('should set cancel link to / when selected context is self', async () => {
+ it('should set cancel link to /self when selected context is self', async () => {
const selectedContext = SelectedContextType.Self;
- (useParams as jest.Mock).mockReturnValue({ selectedContext });
+ const subroute = Subroute.AppDashboard;
+ (useParams as jest.Mock).mockReturnValue({ selectedContext, subroute });
renderWithMockServices();
const cancelLink: HTMLElement = screen.getByRole('link', {
name: textMock('general.cancel'),
});
- expect(cancelLink.getAttribute('href')).toBe(DASHBOARD_ROOT_ROUTE);
+ expect(cancelLink.getAttribute('href')).toBe(`/${subroute}/${selectedContext}`);
});
it('should set cancel link to /all when selected context is all', async () => {
const selectedContext = SelectedContextType.All;
- (useParams as jest.Mock).mockReturnValue({ selectedContext });
+ const subroute = Subroute.AppDashboard;
+ (useParams as jest.Mock).mockReturnValue({ selectedContext, subroute });
renderWithMockServices();
const cancelLink: HTMLElement = screen.getByRole('link', {
name: textMock('general.cancel'),
});
- expect(cancelLink.getAttribute('href')).toBe(DASHBOARD_ROOT_ROUTE + selectedContext);
+ expect(cancelLink.getAttribute('href')).toBe(`/${subroute}/${selectedContext}`);
});
it('should set cancel link to /org when selected context is org', async () => {
const selectedContext = 'ttd';
- (useParams as jest.Mock).mockReturnValue({ selectedContext });
+ const subroute = Subroute.AppDashboard;
+ (useParams as jest.Mock).mockReturnValue({ selectedContext, subroute });
renderWithMockServices();
const cancelLink: HTMLElement = screen.getByRole('link', {
name: textMock('general.cancel'),
});
- expect(cancelLink.getAttribute('href')).toBe(DASHBOARD_ROOT_ROUTE + selectedContext);
+ expect(cancelLink.getAttribute('href')).toBe(`/${subroute}/${selectedContext}`);
});
});
diff --git a/frontend/dashboard/pages/CreateService/CreateService.tsx b/frontend/dashboard/pages/CreateService/CreateService.tsx
index e3cc424e787..03d19d7d392 100644
--- a/frontend/dashboard/pages/CreateService/CreateService.tsx
+++ b/frontend/dashboard/pages/CreateService/CreateService.tsx
@@ -10,9 +10,8 @@ import { ServerCodes } from 'app-shared/enums/ServerCodes';
import { NewApplicationForm } from '../../components/NewApplicationForm';
import { PackagesRouter } from 'app-shared/navigation/PackagesRouter';
import { type NewAppForm } from '../../types/NewAppForm';
-import { DASHBOARD_ROOT_ROUTE } from 'app-shared/constants';
import { useSelectedContext } from '../../hooks/useSelectedContext';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { useSubroute } from '../../hooks/useSubRoute';
const initialFormError: NewAppForm = {
org: '',
@@ -38,6 +37,9 @@ export const CreateService = ({ user, organizations }: CreateServiceProps): JSX.
const [formError, setFormError] = useState(initialFormError);
+ const selectedContext = useSelectedContext();
+ const subroute = useSubroute();
+
const navigateToEditorOverview = (org: string, app: string) => {
const packagesRouter = new PackagesRouter({
org,
@@ -74,8 +76,6 @@ export const CreateService = ({ user, organizations }: CreateServiceProps): JSX.
);
};
- const selectedContext = useSelectedContext();
-
return (
diff --git a/frontend/dashboard/pages/Dashboard/Dashboard.test.tsx b/frontend/dashboard/pages/Dashboard/Dashboard.test.tsx
index a7aa682d584..da8a82bfeec 100644
--- a/frontend/dashboard/pages/Dashboard/Dashboard.test.tsx
+++ b/frontend/dashboard/pages/Dashboard/Dashboard.test.tsx
@@ -3,7 +3,7 @@ import { screen, waitForElementToBeRemoved, within } from '@testing-library/reac
import { Dashboard } from './Dashboard';
import { textMock } from '@studio/testing/mocks/i18nMock';
import type { User } from 'app-shared/types/Repository';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
import { repository, searchRepositoryResponse } from 'app-shared/mocks/mocks';
import type { SearchRepositoryResponse } from 'app-shared/types/api';
diff --git a/frontend/dashboard/pages/Dashboard/Dashboard.tsx b/frontend/dashboard/pages/Dashboard/Dashboard.tsx
index ffffd59d42d..cb72ebe8b13 100644
--- a/frontend/dashboard/pages/Dashboard/Dashboard.tsx
+++ b/frontend/dashboard/pages/Dashboard/Dashboard.tsx
@@ -17,8 +17,10 @@ import type { User } from 'app-shared/types/Repository';
import type { Organization } from 'app-shared/types/Organization';
import { useSelectedContext } from 'dashboard/hooks/useSelectedContext';
import { ResourcesRepoList } from 'dashboard/components/ResourcesRepoList/ResourcesRepoList';
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { SafeErrorView } from '../../components/SafeErrorView';
+import { DASHBOARD_BASENAME } from 'app-shared/constants';
+import { useSubroute } from '../../hooks/useSubRoute';
type DashboardProps = {
user: User;
@@ -29,6 +31,7 @@ type DashboardProps = {
export const Dashboard = ({ user, organizations, disableDebounce }: DashboardProps) => {
const { t } = useTranslation();
const selectedContext = useSelectedContext();
+ const subroute = useSubroute();
const [searchText, setSearchText] = useState('');
const [debouncedSearchText, setDebouncedSearchText] = useState('');
const { debounce } = useDebounce({ debounceTimeInMs: disableDebounce ? 1 : 500 });
@@ -55,7 +58,10 @@ export const Dashboard = ({ user, organizations, disableDebounce }: DashboardPro
onKeyDown={handleKeyDown}
onClear={handleClearSearch}
/>
-
+
{t('dashboard.new_service')}
diff --git a/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.test.tsx b/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.test.tsx
new file mode 100644
index 00000000000..78cbfff8951
--- /dev/null
+++ b/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.test.tsx
@@ -0,0 +1,228 @@
+import React from 'react';
+import { OrgContentLibrary } from './OrgContentLibrary';
+import type { RenderResult } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
+import { textMock } from '@studio/testing/mocks/i18nMock';
+import type { ProviderData } from '../../testing/mocks';
+import { renderWithProviders } from '../../testing/mocks';
+import userEvent, { type UserEvent } from '@testing-library/user-event';
+import type { QueryClient } from '@tanstack/react-query';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import { org } from '@studio/testing/testids';
+import type { CodeListData } from '@studio/content-library';
+import type { CodeList } from '@studio/components';
+import { queriesMock } from 'app-shared/mocks/queriesMock';
+import { SelectedContextType } from '../../enums/SelectedContextType';
+import { Route, Routes } from 'react-router-dom';
+
+const updateCodeListButtonTextMock: string = 'Update Code List';
+const uploadCodeListButtonTextMock: string = 'Upload Code List';
+const deleteCodeListButtonTextMock: string = 'Delete Code List';
+const codeListNameMock: string = 'codeListNameMock';
+const codeListMock: CodeList = [{ value: '', label: '' }];
+const codeListsDataMock: CodeListData[] = [{ title: codeListNameMock, data: codeListMock }];
+const mockOrgPath: string = '/testOrg';
+
+jest.mock(
+ '../../../libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage',
+ () => ({
+ CodeListPage: ({ onDeleteCodeList, onUpdateCodeList, onUploadCodeList }: any) => (
+
+ onUpdateCodeList({ title: codeListNameMock, codeList: codeListMock })}
+ >
+ {updateCodeListButtonTextMock}
+
+
+ onUploadCodeList(
+ new File(['test'], `${codeListNameMock}.json`, { type: 'application/json' }),
+ )
+ }
+ >
+ {uploadCodeListButtonTextMock}
+
+ onDeleteCodeList(codeListsDataMock[0].title)}>
+ {deleteCodeListButtonTextMock}
+
+
+ ),
+ }),
+);
+
+jest.mock('react-router-dom', () => jest.requireActual('react-router-dom')); // Todo: Remove this when we have removed the global mock: https://github.com/Altinn/altinn-studio/issues/14597
+
+describe('OrgContentLibrary', () => {
+ afterEach(jest.clearAllMocks);
+
+ it('renders a spinner when waiting for code lists', () => {
+ renderOrgContentLibrary({ initialEntries: [mockOrgPath] });
+ const spinner = screen.getByText(textMock('general.loading'));
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it('Renders an error message when the code lists query fails', async () => {
+ const getCodeListsForOrg = () => Promise.reject(new Error('Test error'));
+ renderOrgContentLibrary({
+ queries: { getCodeListsForOrg },
+ queryClient: createQueryClientMock(),
+ initialEntries: [mockOrgPath],
+ });
+ await waitFor(expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument);
+ const errorMessage = screen.getByText(textMock('dashboard.org_library.fetch_error'));
+ expect(errorMessage).toBeInTheDocument();
+ });
+
+ it.each([SelectedContextType.None, SelectedContextType.All, SelectedContextType.Self])(
+ 'renders alert and omits library content when context is %s',
+ (selectedContext) => {
+ renderOrgContentLibrary({ initialEntries: ['/' + selectedContext] });
+
+ const noOrgSelectedParagraph = screen.getByText(
+ textMock('dashboard.org_library.alert_no_org_selected'),
+ );
+ expect(noOrgSelectedParagraph).toBeInTheDocument();
+
+ const libraryTitle = screen.queryByRole('heading', {
+ name: textMock('app_content_library.library_heading'),
+ });
+ expect(libraryTitle).not.toBeInTheDocument();
+ },
+ );
+
+ it('renders the library title', async () => {
+ renderOrgContentLibrary({ initialEntries: ['/some-org'] });
+ await waitFor(expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument);
+ const libraryTitle = screen.getByRole('heading', {
+ name: textMock('app_content_library.library_heading'),
+ });
+ expect(libraryTitle).toBeInTheDocument();
+ });
+
+ it('renders the library landing page by default', () => {
+ renderOrgContentLibrary({ initialEntries: ['/some-org'] });
+ const landingPageTitle = screen.getByRole('heading', {
+ name: textMock('app_content_library.landing_page.title'),
+ });
+ expect(landingPageTitle).toBeInTheDocument();
+ const landingPageDescription = screen.getByText(
+ textMock('app_content_library.landing_page.description'),
+ );
+ expect(landingPageDescription).toBeInTheDocument();
+ });
+
+ it('renders the code list menu element', () => {
+ renderOrgContentLibrary({ initialEntries: ['/some-org'] });
+ const codeListMenuElement = screen.getByRole('tab', {
+ name: textMock('app_content_library.code_lists.page_name'),
+ });
+ expect(codeListMenuElement).toBeInTheDocument();
+ });
+
+ it('calls onUpdateCodeList when onUpdateCodeList is triggered', async () => {
+ const user = userEvent.setup();
+ renderOrgContentLibraryWithCodeLists({ initialEntries: [mockOrgPath] });
+ await goToLibraryPage(user, 'code_lists');
+ const updateCodeListButton = screen.getByRole('button', { name: updateCodeListButtonTextMock });
+ await user.click(updateCodeListButton);
+ expect(queriesMock.updateCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.updateCodeListForOrg).toHaveBeenCalledWith(
+ org,
+ codeListNameMock,
+ codeListMock,
+ );
+ });
+
+ it('calls onUploadCodeList when onUploadCodeList is triggered', async () => {
+ const user = userEvent.setup();
+ renderOrgContentLibraryWithCodeLists({ initialEntries: [mockOrgPath] });
+ await goToLibraryPage(user, 'code_lists');
+ const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock });
+ await user.click(uploadCodeListButton);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledWith(org, expect.any(FormData));
+ });
+
+ it('renders success toast when onUploadCodeList is called successfully', async () => {
+ const user = userEvent.setup();
+ renderOrgContentLibraryWithCodeLists({ initialEntries: [mockOrgPath] });
+ await goToLibraryPage(user, 'code_lists');
+ const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock });
+ await user.click(uploadCodeListButton);
+ const successToastMessage = screen.getByText(
+ textMock('dashboard.org_library.code_list_upload_success'),
+ );
+ expect(successToastMessage).toBeInTheDocument();
+ });
+
+ it('renders error toast when onUploadCodeList is rejected with unknown error code', async () => {
+ const user = userEvent.setup();
+ const uploadCodeListForOrg = jest
+ .fn()
+ .mockImplementation(() => Promise.reject({ response: {} }));
+ renderOrgContentLibraryWithCodeLists({
+ initialEntries: [mockOrgPath],
+ queries: { uploadCodeListForOrg },
+ });
+ await goToLibraryPage(user, 'code_lists');
+ const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock });
+ await user.click(uploadCodeListButton);
+ const errorToastMessage = screen.getByText(
+ textMock('dashboard.org_library.code_list_upload_generic_error'),
+ );
+ expect(errorToastMessage).toBeInTheDocument();
+ });
+
+ it('calls onUploadCodeList and hides default error when handleUpload is triggered', async () => {
+ const user = userEvent.setup();
+ renderOrgContentLibraryWithCodeLists({ initialEntries: [mockOrgPath] });
+ await goToLibraryPage(user, 'code_lists');
+ const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock });
+ await user.click(uploadCodeListButton);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledWith(org, expect.any(FormData));
+ const hideDefaultError = screen.queryByText(textMock('dashboard.org_library.default_error'));
+ expect(hideDefaultError).not.toBeInTheDocument();
+ });
+
+ it('calls deleteCodeListForOrg when onDeleteCodeList is triggered', async () => {
+ const user = userEvent.setup();
+ renderOrgContentLibraryWithCodeLists({ initialEntries: ['/testOrg'] });
+ await goToLibraryPage(user, 'code_lists');
+ const deleteCodeListButton = screen.getByRole('button', { name: deleteCodeListButtonTextMock });
+ await user.click(deleteCodeListButton);
+ expect(queriesMock.deleteCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.deleteCodeListForOrg).toHaveBeenCalledWith(org, codeListsDataMock[0].title);
+ });
+});
+
+const getLibraryPageTile = (libraryPage: string) =>
+ screen.getByText(textMock(`app_content_library.${libraryPage}.page_name`));
+
+const goToLibraryPage = async (user: UserEvent, libraryPage: string) => {
+ const libraryPageNavTile = getLibraryPageTile(libraryPage);
+ await user.click(libraryPageNavTile);
+};
+
+function renderOrgContentLibrary(providerData: ProviderData): RenderResult {
+ return renderWithProviders(
+
+ } />
+ ,
+ providerData,
+ );
+}
+
+function renderOrgContentLibraryWithCodeLists(providerData: ProviderData): void {
+ const queryClient = createQueryClientWithOptionsDataList(codeListsDataMock);
+ renderOrgContentLibrary({ ...providerData, queryClient });
+}
+
+function createQueryClientWithOptionsDataList(
+ codeListDataList: CodeListData[] | undefined,
+): QueryClient {
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], codeListDataList);
+ return queryClient;
+}
diff --git a/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.tsx b/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.tsx
new file mode 100644
index 00000000000..dc2f6fd39bb
--- /dev/null
+++ b/frontend/dashboard/pages/OrgContentLibrary/OrgContentLibrary.tsx
@@ -0,0 +1,120 @@
+import type { ReactElement } from 'react';
+import React, { useCallback } from 'react';
+import { ResourceContentLibraryImpl } from '@studio/content-library';
+import type { CodeListData, CodeListWithMetadata } from '@studio/content-library';
+import { useSelectedContext } from '../../hooks/useSelectedContext';
+import {
+ StudioAlert,
+ StudioCenter,
+ StudioParagraph,
+ StudioPageError,
+ StudioPageSpinner,
+} from '@studio/components';
+import { useUpdateOrgCodeListMutation } from 'app-shared/hooks/mutations/useUpdateOrgCodeListMutation';
+import { useTranslation } from 'react-i18next';
+import { isErrorUnknown } from 'app-shared/utils/ApiErrorUtils';
+import type { ApiError } from 'app-shared/types/api/ApiError';
+import { useUploadOrgCodeListMutation } from 'app-shared/hooks/mutations/useUploadOrgCodeListMutation';
+import { toast } from 'react-toastify';
+import type { AxiosError } from 'axios';
+import { useDeleteOrgCodeListMutation } from 'app-shared/hooks/mutations/useDeleteOrgCodeListMutation';
+import { isOrg } from './utils';
+import { useOrgCodeListsQuery } from 'app-shared/hooks/queries/useOrgCodeListsQuery';
+
+export function OrgContentLibrary(): ReactElement {
+ const selectedContext = useSelectedContext();
+
+ return isOrg(selectedContext) ? (
+
+ ) : (
+
+ );
+}
+
+function OrgContentLibraryWithContext(): ReactElement {
+ const { t } = useTranslation();
+ const selectedContext = useSelectedContext();
+
+ const { data: codeListsResponse, status: codeListResponseStatus } =
+ useOrgCodeListsQuery(selectedContext);
+
+ switch (codeListResponseStatus) {
+ case 'pending':
+ return ;
+ case 'error':
+ return ;
+ case 'success':
+ return ;
+ }
+}
+
+type OrgContentLibraryWithContextAndDataProps = {
+ codeListsDataList: CodeListData[];
+};
+
+function OrgContentLibraryWithContextAndData({
+ codeListsDataList,
+}: OrgContentLibraryWithContextAndDataProps): ReactElement {
+ const selectedContext = useSelectedContext();
+
+ const { mutate: updateOptionList } = useUpdateOrgCodeListMutation(selectedContext);
+ const { mutate: deleteCodeList } = useDeleteOrgCodeListMutation(selectedContext);
+
+ const handleUpload = useUploadCodeList(selectedContext);
+
+ const handleUpdate = ({ title, codeList }: CodeListWithMetadata): void => {
+ updateOptionList({ title, data: codeList });
+ };
+
+ const { getContentResourceLibrary } = new ResourceContentLibraryImpl({
+ pages: {
+ codeList: {
+ props: {
+ codeListsData: codeListsDataList,
+ onDeleteCodeList: deleteCodeList,
+ onUpdateCodeListId: () => {},
+ onUpdateCodeList: handleUpdate,
+ onUploadCodeList: handleUpload,
+ },
+ },
+ },
+ });
+
+ return {getContentResourceLibrary()}
;
+}
+
+function ContextWithoutLibraryAccess(): ReactElement {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('dashboard.org_library.alert_no_org_selected')}
+
+ {t('dashboard.org_library.alert_no_org_selected_no_access')}
+
+
+
+ );
+}
+
+function useUploadCodeList(org: string): (file: File) => void {
+ const { mutate: uploadCodeList } = useUploadOrgCodeListMutation(org, {
+ hideDefaultError: (error: AxiosError) => isErrorUnknown(error),
+ });
+ const { t } = useTranslation();
+
+ return useCallback(
+ (file: File) =>
+ uploadCodeList(file, {
+ onSuccess: () => {
+ toast.success(t('dashboard.org_library.code_list_upload_success'));
+ },
+ onError: (error: AxiosError) => {
+ if (isErrorUnknown(error)) {
+ toast.error(t('dashboard.org_library.code_list_upload_generic_error'));
+ }
+ },
+ }),
+ [uploadCodeList, t],
+ );
+}
diff --git a/frontend/dashboard/pages/OrgContentLibrary/index.ts b/frontend/dashboard/pages/OrgContentLibrary/index.ts
new file mode 100644
index 00000000000..c3425656352
--- /dev/null
+++ b/frontend/dashboard/pages/OrgContentLibrary/index.ts
@@ -0,0 +1 @@
+export * from './OrgContentLibrary';
diff --git a/frontend/dashboard/pages/OrgContentLibrary/utils.test.ts b/frontend/dashboard/pages/OrgContentLibrary/utils.test.ts
new file mode 100644
index 00000000000..a2145dd9df2
--- /dev/null
+++ b/frontend/dashboard/pages/OrgContentLibrary/utils.test.ts
@@ -0,0 +1,17 @@
+import { isOrg } from './utils';
+import { SelectedContextType } from '../../enums/SelectedContextType';
+
+describe('utils', () => {
+ describe('isOrg', () => {
+ it('Returns true when the input is not a reserved keyword', () => {
+ expect(isOrg('organisation')).toBe(true);
+ });
+
+ it.each([SelectedContextType.Self, SelectedContextType.All, SelectedContextType.None])(
+ 'Returns false when the input is %s',
+ (contextType) => {
+ expect(isOrg(contextType)).toBe(false);
+ },
+ );
+ });
+});
diff --git a/frontend/dashboard/pages/OrgContentLibrary/utils.ts b/frontend/dashboard/pages/OrgContentLibrary/utils.ts
new file mode 100644
index 00000000000..9863a9c633a
--- /dev/null
+++ b/frontend/dashboard/pages/OrgContentLibrary/utils.ts
@@ -0,0 +1,10 @@
+import { SelectedContextType } from '../../enums/SelectedContextType';
+
+export function isOrg(contextType: string): boolean {
+ const notOrgContexts: string[] = [
+ SelectedContextType.Self,
+ SelectedContextType.All,
+ SelectedContextType.None,
+ ];
+ return !notOrgContexts.includes(contextType);
+}
diff --git a/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.module.css b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.module.css
new file mode 100644
index 00000000000..13dd0977628
--- /dev/null
+++ b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.module.css
@@ -0,0 +1,3 @@
+.active {
+ border-bottom: 2px solid var(--fds-semantic-surface-neutral-default);
+}
diff --git a/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.test.tsx b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.test.tsx
index e0ad60ee309..21c9d6c3a40 100644
--- a/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.test.tsx
+++ b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.test.tsx
@@ -2,19 +2,23 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardHeader } from './DashboardHeader';
-import { HeaderContext, SelectedContextType } from 'dashboard/context/HeaderContext';
+import { Subroute } from '../../../enums/Subroute';
+import { HeaderContext } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../../enums/SelectedContextType';
import { useParams } from 'react-router-dom';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { type User } from 'app-shared/types/Repository';
import { type Organization } from 'app-shared/types/Organization';
import { type HeaderContextType } from 'dashboard/context/HeaderContext';
import { MockServicesContextWrapper } from 'dashboard/dashboardTestUtils';
+import { typedLocalStorage } from '@studio/pure-functions';
+import { FeatureFlag } from 'app-shared/utils/featureToggleUtils';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
- useParams: jest.fn(),
+ useParams: jest.fn().mockReturnValue({ subroute: 'app-dashboard', selectedContext: 'self' }),
}));
const userMock: User = {
@@ -41,9 +45,7 @@ const mockOrg2: Organization = {
const mockOrganizations: Organization[] = [mockOrg1, mockOrg2];
describe('DashboardHeader', () => {
- afterEach(() => {
- jest.clearAllMocks();
- });
+ afterEach(jest.clearAllMocks);
it('should render the user name as the profile button when in self context', () => {
(useParams as jest.Mock).mockReturnValue({
@@ -104,10 +106,67 @@ describe('DashboardHeader', () => {
expect(logoutItem).toBeInTheDocument();
});
+ it('should render correct menu elements in header', () => {
+ typedLocalStorage.setItem('featureFlags', [FeatureFlag.OrgLibrary]);
+ renderDashboardHeader();
+ const libraryMenuItem = screen.getByRole('link', {
+ name: textMock('dashboard.header_item_library'),
+ });
+ expect(libraryMenuItem).toBeInTheDocument();
+ const appsMenuItem = screen.getByRole('link', {
+ name: textMock('dashboard.header_item_dashboard'),
+ });
+ expect(appsMenuItem).toBeInTheDocument();
+ typedLocalStorage.removeItem('featureFlags');
+ });
+
+ it('should render library menu element with correct link', () => {
+ typedLocalStorage.setItem('featureFlags', FeatureFlag.OrgLibrary);
+ renderDashboardHeader();
+ const libraryMenuItem = screen.getByRole('link', {
+ name: textMock('dashboard.header_item_library'),
+ });
+ expect(libraryMenuItem).toHaveAttribute(
+ 'href',
+ `${Subroute.OrgLibrary}/${SelectedContextType.Self}`,
+ );
+ typedLocalStorage.removeItem('featureFlags');
+ });
+
+ it('should not render library menu element when featureFlag is not turned on', () => {
+ renderDashboardHeader();
+ const libraryMenuItem = screen.queryByRole('link', {
+ name: textMock('dashboard.header_item_library'),
+ });
+ expect(libraryMenuItem).not.toBeInTheDocument();
+ });
+
+ it('should not render dashboard menu element when featureFlag is not turned on', () => {
+ renderDashboardHeader();
+ const dashboardMenuItem = screen.queryByRole('link', {
+ name: textMock('dashboard.header_item_dashboard'),
+ });
+ expect(dashboardMenuItem).not.toBeInTheDocument();
+ });
+
+ it('should render apps menu element with correct link', () => {
+ typedLocalStorage.setItem('featureFlags', FeatureFlag.OrgLibrary);
+ renderDashboardHeader();
+ const appsMenuItem = screen.getByRole('link', {
+ name: textMock('dashboard.header_item_dashboard'),
+ });
+ expect(appsMenuItem).toHaveAttribute(
+ 'href',
+ `${Subroute.AppDashboard}/${SelectedContextType.Self}`,
+ );
+ typedLocalStorage.removeItem('featureFlags');
+ });
+
it('should navigate to the correct organization context when an org is selected', async () => {
const user = userEvent.setup();
(useParams as jest.Mock).mockReturnValue({
selectedContext: SelectedContextType.Self,
+ subroute: Subroute.AppDashboard,
});
renderDashboardHeader();
@@ -118,7 +177,7 @@ describe('DashboardHeader', () => {
const org1Item = screen.getByRole('menuitemradio', { name: mockOrg1.full_name });
await user.click(org1Item);
- expect(mockNavigate).toHaveBeenCalledWith(`/${mockOrg1.username}`);
+ expect(mockNavigate).toHaveBeenCalledWith(`${Subroute.AppDashboard}/${mockOrg1.username}`);
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
@@ -126,6 +185,7 @@ describe('DashboardHeader', () => {
const user = userEvent.setup();
(useParams as jest.Mock).mockReturnValue({
selectedContext: SelectedContextType.Self,
+ subroute: Subroute.AppDashboard,
});
renderDashboardHeader();
@@ -136,7 +196,9 @@ describe('DashboardHeader', () => {
const allItem = screen.getByRole('menuitemradio', { name: textMock('shared.header_all') });
await user.click(allItem);
- expect(mockNavigate).toHaveBeenCalledWith(`/${SelectedContextType.All}`);
+ expect(mockNavigate).toHaveBeenCalledWith(
+ `${Subroute.AppDashboard}/${SelectedContextType.All}`,
+ );
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
@@ -144,6 +206,7 @@ describe('DashboardHeader', () => {
const user = userEvent.setup();
(useParams as jest.Mock).mockReturnValue({
selectedContext: SelectedContextType.All,
+ subroute: Subroute.AppDashboard,
});
renderDashboardHeader();
@@ -154,7 +217,9 @@ describe('DashboardHeader', () => {
const selfItem = screen.getByRole('menuitemradio', { name: userMock.full_name });
await user.click(selfItem);
- expect(mockNavigate).toHaveBeenCalledWith(`/${SelectedContextType.Self}`);
+ expect(mockNavigate).toHaveBeenCalledWith(
+ `${Subroute.AppDashboard}/${SelectedContextType.Self}`,
+ );
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
});
diff --git a/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.tsx b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.tsx
index a879ec30db8..857334c72a1 100644
--- a/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.tsx
+++ b/frontend/dashboard/pages/PageLayout/DashboardHeader/DashboardHeader.tsx
@@ -1,5 +1,7 @@
import React, { useContext } from 'react';
-import { useNavigate } from 'react-router-dom';
+import classes from './DashboardHeader.module.css';
+import cn from 'classnames';
+import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
StudioAvatar,
@@ -11,11 +13,17 @@ import {
import { useSelectedContext } from 'dashboard/hooks/useSelectedContext';
import { type Organization } from 'app-shared/types/Organization';
import { MEDIA_QUERY_MAX_WIDTH } from 'app-shared/constants';
-import { HeaderContext, SelectedContextType } from 'dashboard/context/HeaderContext';
+import { HeaderContext } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../../enums/SelectedContextType';
import { useLogoutMutation } from 'app-shared/hooks/mutations/useLogoutMutation';
import { useProfileMenuTriggerButtonText } from 'dashboard/hooks/useProfileMenuTriggerButtonText';
import { useRepoPath } from 'dashboard/hooks/useRepoPath';
import { usePageHeaderTitle } from 'dashboard/hooks/usePageHeaderTitle';
+import { useSubroute } from '../../../hooks/useSubRoute';
+import type { HeaderMenuItem } from '../../../types/HeaderMenuItem';
+import { dashboardHeaderMenuItems } from '../../../utils/headerUtils';
+import { StringUtils } from '@studio/pure-functions';
+import { FeatureFlag, shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
export const DashboardHeader = () => {
const pageHeaderTitle: string = usePageHeaderTitle();
@@ -24,6 +32,12 @@ export const DashboardHeader = () => {
+
+ {shouldDisplayFeature(FeatureFlag.OrgLibrary) &&
+ dashboardHeaderMenuItems.map((menuItem: HeaderMenuItem) => (
+
+ ))}
+
@@ -32,10 +46,46 @@ export const DashboardHeader = () => {
);
};
+type TopNavigationMenuProps = {
+ menuItem: HeaderMenuItem;
+};
+
+function TopNavigationMenuItem({ menuItem }: TopNavigationMenuProps): React.ReactElement {
+ const selectedContext: string = useSelectedContext();
+ const { t } = useTranslation();
+ const location = useLocation();
+ const path: string = `${menuItem.link}/${selectedContext}`;
+ const currentRoutePath: string = extractSecondLastRouterParam(location.pathname);
+
+ return (
+ (
+
+
+ {t(menuItem.name)}
+
+
+ )}
+ />
+ );
+}
+
+function extractSecondLastRouterParam(pathname: string): string {
+ const pathnameArray = pathname.split('/');
+ return pathnameArray[pathnameArray.length - 2];
+}
+
const DashboardHeaderMenu = () => {
const { t } = useTranslation();
const showButtonText = !useMediaQuery(MEDIA_QUERY_MAX_WIDTH);
const selectedContext = useSelectedContext();
+ const subroute = useSubroute();
const { mutate: logout } = useLogoutMutation();
const { user, selectableOrgs } = useContext(HeaderContext);
const navigate = useNavigate();
@@ -44,7 +94,7 @@ const DashboardHeaderMenu = () => {
const repoPath = useRepoPath();
const handleSetSelectedContext = (context: string | SelectedContextType) => {
- navigate('/' + context + location.search);
+ navigate(`${subroute}/${context}${location.search}`);
};
const allMenuItem: StudioProfileMenuItem = {
@@ -83,7 +133,7 @@ const DashboardHeaderMenu = () => {
return (
({
@@ -72,13 +74,19 @@ describe('PageLayout', () => {
});
renderWithMockServices();
expect(mockedNavigate).toHaveBeenCalledTimes(1);
- expect(mockedNavigate).toHaveBeenCalledWith(SelectedContextType.Self, expect.anything());
+ expect(mockedNavigate).toHaveBeenCalledWith(
+ `${StringUtils.removeLeadingSlash(Subroute.AppDashboard)}/${SelectedContextType.Self}`,
+ expect.anything(),
+ );
});
it('should redirect to self context if none is defined', async () => {
renderWithMockServices();
expect(mockedNavigate).toHaveBeenCalledTimes(1);
- expect(mockedNavigate).toHaveBeenCalledWith(SelectedContextType.Self, expect.anything());
+ expect(mockedNavigate).toHaveBeenCalledWith(
+ `${StringUtils.removeLeadingSlash(Subroute.AppDashboard)}/${SelectedContextType.Self}`,
+ expect.anything(),
+ );
});
it.each([['self', 'all', 'ttd']])(
@@ -89,7 +97,10 @@ describe('PageLayout', () => {
});
sessionStorage.setItem('dashboard::selectedContext', `"${context}"`);
renderWithMockServices();
- expect(mockedNavigate).toHaveBeenCalledWith(context, expect.anything());
+ expect(mockedNavigate).toHaveBeenCalledWith(
+ `${StringUtils.removeLeadingSlash(Subroute.AppDashboard)}/${context}`,
+ expect.anything(),
+ );
},
);
@@ -98,6 +109,9 @@ describe('PageLayout', () => {
sessionStorage.setItem('dashboard::selectedContext', '"testinvalidcontext"');
renderWithMockServices();
expect(mockedNavigate).toHaveBeenCalledTimes(1);
- expect(mockedNavigate).toHaveBeenCalledWith(SelectedContextType.Self, expect.anything());
+ expect(mockedNavigate).toHaveBeenCalledWith(
+ `${StringUtils.removeLeadingSlash(Subroute.AppDashboard)}/${SelectedContextType.Self}`,
+ expect.anything(),
+ );
});
});
diff --git a/frontend/dashboard/testing/mocks.tsx b/frontend/dashboard/testing/mocks.tsx
index 0acf1ad9c5f..c77569cb027 100644
--- a/frontend/dashboard/testing/mocks.tsx
+++ b/frontend/dashboard/testing/mocks.tsx
@@ -3,6 +3,7 @@ import type { Queries, RenderHookOptions, RenderOptions } from '@testing-library
import { render, renderHook } from '@testing-library/react';
import React from 'react';
import type { ReactNode } from 'react';
+import type { MemoryRouterProps } from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
import {
type ServicesContextProps,
@@ -14,34 +15,33 @@ import type { QueryClient } from '@tanstack/react-query';
type WrapperArgs = {
queries: Partial;
queryClient: QueryClient;
-};
+} & Pick;
const wrapper =
- ({ queries = {}, queryClient = queryClientMock }: WrapperArgs) =>
+ ({ queries = {}, queryClient = queryClientMock, initialEntries }: WrapperArgs) =>
// eslint-disable-next-line react/display-name
(component: ReactNode) => (
-
+
{component}
);
-export interface ProviderData {
- queries?: Partial;
- queryClient?: QueryClient;
+export interface ProviderData extends Partial {
externalWrapper?: (children: ReactNode) => ReactNode;
}
export function renderWithProviders(
component: ReactNode,
- { queries = {}, queryClient = queryClientMock }: ProviderData = {},
+ { queries = {}, queryClient = queryClientMock, initialEntries }: ProviderData = {},
) {
const renderOptions: RenderOptions = {
wrapper: ({ children }) =>
wrapper({
queries,
queryClient,
+ initialEntries,
})(children),
};
return render(component, renderOptions);
@@ -53,6 +53,7 @@ export function renderHookWithProviders(
queries = {},
queryClient = queryClientMock,
externalWrapper = (children) => children,
+ initialEntries,
}: ProviderData = {},
) {
const renderHookOptions: RenderHookOptions = {
@@ -61,6 +62,7 @@ export function renderHookWithProviders(
wrapper({
queries,
queryClient,
+ initialEntries,
})(children),
),
};
diff --git a/frontend/dashboard/types/HeaderMenuItem.ts b/frontend/dashboard/types/HeaderMenuItem.ts
new file mode 100644
index 00000000000..a7a5a9e9127
--- /dev/null
+++ b/frontend/dashboard/types/HeaderMenuItem.ts
@@ -0,0 +1,7 @@
+import type { HeaderMenuItemKey } from '../enums/HeaderMenuItemKey';
+
+export type HeaderMenuItem = {
+ key: HeaderMenuItemKey;
+ link: string;
+ name: string;
+};
diff --git a/frontend/dashboard/utils/filterUtils/filterUtils.test.ts b/frontend/dashboard/utils/filterUtils/filterUtils.test.ts
index bf51902f1fe..341dc5f090f 100644
--- a/frontend/dashboard/utils/filterUtils/filterUtils.test.ts
+++ b/frontend/dashboard/utils/filterUtils/filterUtils.test.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { getUidFilter } from './filterUtils';
describe('getUidFilter', () => {
diff --git a/frontend/dashboard/utils/filterUtils/filterUtils.ts b/frontend/dashboard/utils/filterUtils/filterUtils.ts
index 45c450409b7..4d10b659ac9 100644
--- a/frontend/dashboard/utils/filterUtils/filterUtils.ts
+++ b/frontend/dashboard/utils/filterUtils/filterUtils.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import type { Organization } from 'app-shared/types/Organization';
type GetUidFilter = {
diff --git a/frontend/dashboard/utils/headerUtils/headerUtils.ts b/frontend/dashboard/utils/headerUtils/headerUtils.ts
new file mode 100644
index 00000000000..e5bb1a11844
--- /dev/null
+++ b/frontend/dashboard/utils/headerUtils/headerUtils.ts
@@ -0,0 +1,16 @@
+import type { HeaderMenuItem } from '../../types/HeaderMenuItem';
+import { Subroute } from '../../enums/Subroute';
+import { HeaderMenuItemKey } from '../../enums/HeaderMenuItemKey';
+
+export const dashboardHeaderMenuItems: HeaderMenuItem[] = [
+ {
+ key: HeaderMenuItemKey.OrgLibrary,
+ link: Subroute.OrgLibrary,
+ name: 'dashboard.header_item_library',
+ },
+ {
+ key: HeaderMenuItemKey.AppDashboard,
+ link: Subroute.AppDashboard,
+ name: 'dashboard.header_item_dashboard',
+ },
+];
diff --git a/frontend/dashboard/utils/headerUtils/index.ts b/frontend/dashboard/utils/headerUtils/index.ts
new file mode 100644
index 00000000000..df8a8b8be5d
--- /dev/null
+++ b/frontend/dashboard/utils/headerUtils/index.ts
@@ -0,0 +1 @@
+export { dashboardHeaderMenuItems } from './headerUtils';
diff --git a/frontend/dashboard/utils/repoUtils/repoUtils.test.ts b/frontend/dashboard/utils/repoUtils/repoUtils.test.ts
index 3aae54e5c72..f0cc2d89d3f 100644
--- a/frontend/dashboard/utils/repoUtils/repoUtils.test.ts
+++ b/frontend/dashboard/utils/repoUtils/repoUtils.test.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import { getReposLabel, validateRepoName } from './repoUtils';
import { textMock } from '@studio/testing/mocks/i18nMock';
import type { Organization } from 'app-shared/types/Organization';
diff --git a/frontend/dashboard/utils/repoUtils/repoUtils.ts b/frontend/dashboard/utils/repoUtils/repoUtils.ts
index 11d5f848e03..21104e24e46 100644
--- a/frontend/dashboard/utils/repoUtils/repoUtils.ts
+++ b/frontend/dashboard/utils/repoUtils/repoUtils.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import type { Repository } from 'app-shared/types/Repository';
import type { Organization } from 'app-shared/types/Organization';
import type i18next from 'i18next';
diff --git a/frontend/dashboard/utils/userUtils/userUtils.test.ts b/frontend/dashboard/utils/userUtils/userUtils.test.ts
index 17b11351898..083af243687 100644
--- a/frontend/dashboard/utils/userUtils/userUtils.test.ts
+++ b/frontend/dashboard/utils/userUtils/userUtils.test.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import {
getOrgNameByUsername,
getOrgUsernameByUsername,
diff --git a/frontend/dashboard/utils/userUtils/userUtils.ts b/frontend/dashboard/utils/userUtils/userUtils.ts
index 4e5e8085f89..8a25189d79a 100644
--- a/frontend/dashboard/utils/userUtils/userUtils.ts
+++ b/frontend/dashboard/utils/userUtils/userUtils.ts
@@ -1,4 +1,4 @@
-import { SelectedContextType } from 'dashboard/context/HeaderContext';
+import { SelectedContextType } from '../../enums/SelectedContextType';
import type { Organization } from 'app-shared/types/Organization';
export const userHasAccessToSelectedContext = ({
diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json
index f7fdad2be41..ae948dc6a32 100644
--- a/frontend/language/src/nb.json
+++ b/frontend/language/src/nb.json
@@ -226,6 +226,8 @@
"dashboard.favourites": "Favoritter",
"dashboard.field_cannot_be_empty": "Dette feltet kan ikke være tomt",
"dashboard.go_to_resources": "Gå til ressurser-dashboard",
+ "dashboard.header_item_dashboard": "Dashbord",
+ "dashboard.header_item_library": "Bibliotek",
"dashboard.last_modified": "Sist endret",
"dashboard.loading": "Laster inn dashboardet",
"dashboard.loading_resource_list": "Laster inn ressursliste",
@@ -241,6 +243,10 @@
"dashboard.open_repository": "Åpne repository",
"dashboard.org_apps": "{{orgName}}s apper",
"dashboard.org_data_models": "{{orgName}}s datamodeller",
+ "dashboard.org_library.alert_no_org_selected": "For å vise biblioteket, må du velge en organisasjon i menyen øverst til høyre.",
+ "dashboard.org_library.alert_no_org_selected_no_access": "Hvis du ikke har tilgang til noen organisasjoner, må du be om tilgang fra en tjenesteeier.",
+ "dashboard.org_library.code_list_upload_success": "Filen ble lastet opp.",
+ "dashboard.org_library.fetch_error": "Det har oppstått et problem ved henting av data til biblioteket.",
"dashboard.org_resources": "{{orgName}}s ressurser",
"dashboard.resource_contact_description": "Se oversikt over hvordan du kommer i kontakt med oss i Altinn Studio.",
"dashboard.resource_contact_label": "Kontakt oss",
diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx
index c89dc35262b..ba3424c589c 100644
--- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx
+++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx
@@ -34,7 +34,7 @@ export type CodeListPageProps = {
onUpdateCodeList: (updatedCodeList: CodeListWithMetadata) => void;
onUpdateTextResource?: (textResource: TextResourceWithLanguage) => void;
onUploadCodeList: (uploadedCodeList: File) => void;
- codeListsUsages: CodeListReference[];
+ codeListsUsages?: CodeListReference[];
textResources?: TextResources;
};
diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.test.ts b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.test.ts
index ef52cb45fa7..f1359bebd35 100644
--- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.test.ts
+++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.test.ts
@@ -50,6 +50,11 @@ describe('utils', () => {
const codeListSources = getCodeListSourcesById([], codeListId1);
expect(codeListSources).toEqual([]);
});
+
+ it('returns an empty array if codeListUsages and codeListTitle are undefined', () => {
+ const codeListSources = getCodeListSourcesById(undefined, undefined);
+ expect(codeListSources).toEqual([]);
+ });
});
describe('getCodeListUsageCount', () => {
diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.ts b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.ts
index c57a51aa619..51b18ae35fb 100644
--- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.ts
+++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/utils/utils.ts
@@ -5,10 +5,10 @@ import type { TextResource } from '@studio/components';
import type { TextResourceWithLanguage } from '../../../../../types/TextResourceWithLanguage';
export const getCodeListSourcesById = (
- codeListsUsages: CodeListReference[],
+ codeListsUsages: CodeListReference[] | undefined,
codeListTitle: string,
): CodeListIdSource[] => {
- const codeListUsages: CodeListReference | undefined = codeListsUsages.find(
+ const codeListUsages: CodeListReference | undefined = codeListsUsages?.find(
(codeListUsage) => codeListUsage.codeListId === codeListTitle,
);
return codeListUsages?.codeListIdSources ?? [];
diff --git a/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.test.ts b/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.test.ts
new file mode 100644
index 00000000000..b421f4d8d39
--- /dev/null
+++ b/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.test.ts
@@ -0,0 +1,17 @@
+import { FileUtils } from './FileUtils';
+
+describe('FileUtils', () => {
+ describe('convertToFormData', () => {
+ it('should append the file to FormData under the key "file"', () => {
+ const fileContent = 'Test file contents';
+ const fileName = 'test.txt';
+ const fileType = 'text/plain';
+ const file = new File([fileContent], fileName, { type: fileType });
+
+ const formData = FileUtils.convertToFormData(file);
+
+ const retrievedFile = formData.get('file');
+ expect(retrievedFile).toBe(file);
+ });
+ });
+});
diff --git a/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.ts b/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.ts
new file mode 100644
index 00000000000..618bfbc780a
--- /dev/null
+++ b/frontend/libs/studio-pure-functions/src/FileUtils/FileUtils.ts
@@ -0,0 +1,7 @@
+export class FileUtils {
+ static convertToFormData = (file: File): FormData => {
+ const formData = new FormData();
+ formData.append('file', file);
+ return formData;
+ };
+}
diff --git a/frontend/libs/studio-pure-functions/src/FileUtils/index.ts b/frontend/libs/studio-pure-functions/src/FileUtils/index.ts
new file mode 100644
index 00000000000..eaec5c5c5a3
--- /dev/null
+++ b/frontend/libs/studio-pure-functions/src/FileUtils/index.ts
@@ -0,0 +1 @@
+export { FileUtils } from './FileUtils';
diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts
index 3731f936869..5022e1962dd 100644
--- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts
+++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts
@@ -137,4 +137,28 @@ describe('StringUtils', () => {
expect(StringUtils.areCaseInsensitiveEqual('', 'abc')).toBe(false);
});
});
+
+ describe('removeLeadingSlash', () => {
+ it('Removes leading slash from string', () => {
+ expect(StringUtils.removeLeadingSlash('/abc')).toBe('abc');
+ });
+
+ it('Does not remove anything if there is no leading slash', () => {
+ expect(StringUtils.removeLeadingSlash('abc')).toBe('abc');
+ });
+
+ it('Does not remove anything when there are slashes in other places than the start', () => {
+ expect(StringUtils.removeLeadingSlash('a/b/c/')).toBe('a/b/c/');
+ });
+
+ it('Removes the first slash only when there are multiple', () => {
+ expect(StringUtils.removeLeadingSlash('//a/b/c/')).toBe('/a/b/c/');
+ });
+
+ it('Does not change the input string object', () => {
+ const input = '/abc';
+ StringUtils.removeLeadingSlash(input);
+ expect(input).toBe('/abc');
+ });
+ });
});
diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts
index d1ec0006dfb..a2b781e156b 100644
--- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts
+++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts
@@ -85,4 +85,6 @@ export class StringUtils {
static areCaseInsensitiveEqual = (string: string, stringToCompare: string): boolean =>
string.localeCompare(stringToCompare, 'nb', { sensitivity: 'base' }) === 0;
+
+ static removeLeadingSlash = (str: string): string => str.replace(/^\//g, '');
}
diff --git a/frontend/libs/studio-pure-functions/src/index.ts b/frontend/libs/studio-pure-functions/src/index.ts
index 566087a5f25..7fde05af356 100644
--- a/frontend/libs/studio-pure-functions/src/index.ts
+++ b/frontend/libs/studio-pure-functions/src/index.ts
@@ -2,6 +2,7 @@ export * from './ArrayUtils';
export * from './BlobDownloader';
export * from './DateUtils';
export * from './FileNameUtils';
+export * from './FileUtils';
export * from './NumberUtils';
export * from './ObjectUtils';
export * from './ScopedStorage';
diff --git a/frontend/packages/shared/src/api/mutations.ts b/frontend/packages/shared/src/api/mutations.ts
index acbeea332e9..098c0ce18ff 100644
--- a/frontend/packages/shared/src/api/mutations.ts
+++ b/frontend/packages/shared/src/api/mutations.ts
@@ -49,6 +49,8 @@ import {
dataTypePath,
optionListPath,
undeployAppFromEnvPath,
+ orgCodeListPath,
+ orgCodeListUploadPath,
layoutPagesPath,
} from 'app-shared/api/paths';
import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload';
@@ -58,7 +60,7 @@ import type { CreateDeploymentPayload } from 'app-shared/types/api/CreateDeploym
import type { CreateReleasePayload } from 'app-shared/types/api/CreateReleasePayload';
import type { CreateRepoCommitPayload } from 'app-shared/types/api/CreateRepoCommitPayload';
import type { LayoutSetPayload } from 'app-shared/types/api/LayoutSetPayload';
-import type { ILayoutSettings, ITextResourcesObjectFormat } from 'app-shared/types/global';
+import type { ILayoutSettings, ITextResource, ITextResourcesObjectFormat, ITextResourcesWithLanguage } from 'app-shared/types/global';
import type { RuleConfig } from 'app-shared/types/RuleConfig';
import type { UpdateTextIdPayload } from 'app-shared/types/api/UpdateTextIdPayload';
import { buildQueryParams } from 'app-shared/utils/urlUtils';
@@ -76,6 +78,9 @@ import type { FormLayoutRequest } from 'app-shared/types/api/FormLayoutRequest';
import type { Option } from 'app-shared/types/Option';
import type { MaskinportenScopes } from 'app-shared/types/MaskinportenScope';
import type { DataType } from '../types/DataType';
+import type { CodeList } from 'app-shared/types/CodeList';
+import type { CodeListsResponse } from 'app-shared/types/api/CodeListsResponse';
+import { textResourcesMock } from 'app-shared/mocks/textResourcesMock';
import type { PageModel } from '../types/api/dto/PageModel';
import type { PagesModel } from '../types/api/dto/PagesModel';
@@ -174,3 +179,14 @@ export const updateProcessDataTypes = (org: string, app: string, dataTypesChange
// Maskinporten
export const updateSelectedMaskinportenScopes = (org: string, app: string, appScopesUpsertRequest: MaskinportenScopes) => put(selectedMaskinportenScopesPath(org, app), appScopesUpsertRequest);
+
+// Organisation library code lists:
+export const createCodeListForOrg = async (org: string, codeListId: string, payload: CodeList): Promise => post(orgCodeListPath(org, codeListId), payload);
+export const updateCodeListForOrg = async (org: string, codeListId: string, payload: CodeList): Promise => put(orgCodeListPath(org, codeListId), payload);
+export const deleteCodeListForOrg = async (org: string, codeListId: string): Promise => del(orgCodeListPath(org, codeListId));
+export const uploadCodeListForOrg = async (org: string, payload: FormData): Promise => post(orgCodeListUploadPath(org), payload);
+
+// Organisation text resources:
+// Todo: Replace these with real API calls when endpoints are ready. https://github.com/Altinn/altinn-studio/issues/14503
+export const createTextResourcesForOrg = async (org: string, language: string): Promise => Promise.resolve([textResourcesMock]); // Todo: Replace with real API call when endpoint is ready. https://github.com/Altinn/altinn-studio/issues/14503
+export const updateTextResourcesForOrg = async (org: string, language: string, payload: ITextResource[]): Promise => Promise.resolve([textResourcesMock]); // Todo: Replace with real API call when endpoint is ready. https://github.com/Altinn/altinn-studio/issues/14503
diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js
index c62f0c2eaeb..4062ddda2e8 100644
--- a/frontend/packages/shared/src/api/paths.js
+++ b/frontend/packages/shared/src/api/paths.js
@@ -87,6 +87,11 @@ export const imagePath = (org, app, imageFilePath) => `${basePath}/${org}/${app}
export const validateImageFromExternalUrlPath = (org, app, url) => `${basePath}/${org}/${app}/images/validate?${s({ url })}`; // Get
export const getImageFileNamesPath = (org, app) => `${basePath}/${org}/${app}/images/fileNames`; // Get
+// Library - org-level
+export const orgCodeListsPath = (org) => `${basePath}/${org}/code-lists`; // Get
+export const orgCodeListPath = (org, codeListId) => `${basePath}/${org}/code-lists/${codeListId}`; // Post, Put, Delete
+export const orgCodeListUploadPath = (org) => `${basePath}/${org}/code-lists/upload`; // Post
+
// Organizations
export const orgsListPath = () => `${basePath}/orgs`; // Get
diff --git a/frontend/packages/shared/src/api/queries.ts b/frontend/packages/shared/src/api/queries.ts
index 4020861147e..ec13eecaebd 100644
--- a/frontend/packages/shared/src/api/queries.ts
+++ b/frontend/packages/shared/src/api/queries.ts
@@ -62,6 +62,7 @@ import {
optionListReferencesPath,
userOrgPermissionsPath,
dataTypePath,
+ orgCodeListsPath,
layoutPagesPath,
} from './paths';
@@ -100,6 +101,8 @@ import type { OptionListReferences } from 'app-shared/types/OptionListReferences
import type { LayoutSetsModel } from '../types/api/dto/LayoutSetsModel';
import type { AccessPackageResource, PolicyAccessPackageAreaGroup } from 'app-shared/types/PolicyAccessPackages';
import type { DataType } from '../types/DataType';
+import type { CodeListsResponse } from '../types/api/CodeListsResponse';
+import { textResourcesMock } from '../mocks/textResourcesMock';
import type { PagesModel } from '../types/api/dto/PagesModel';
export const getIsLoggedInWithAnsattporten = () => get<{ isLoggedIn: boolean }>(authStatusAnsattporten());
@@ -181,3 +184,7 @@ export const getProcessTaskType = (org: string, app: string, taskId: string) =>
// Contact Page
export const fetchBelongsToGiteaOrg = () => get(belongsToOrg());
+
+// Organisation library
+export const getCodeListsForOrg = (org: string) => get(orgCodeListsPath(org));
+export const getTextResourcesForOrg = async (org: string, language: string): Promise => Promise.resolve(textResourcesMock); // Todo: Replace with real API call when endpoint is ready. https://github.com/Altinn/altinn-studio/issues/14503
diff --git a/frontend/packages/shared/src/constants.js b/frontend/packages/shared/src/constants.js
index d7776d6dbfd..521891df7d5 100644
--- a/frontend/packages/shared/src/constants.js
+++ b/frontend/packages/shared/src/constants.js
@@ -1,9 +1,10 @@
// TODO: Extract/Centralize react-router routes (https://github.com/Altinn/altinn-studio/issues/12624)
export const APP_DEVELOPMENT_BASENAME = '/editor';
+export const APP_DASHBOARD_BASENAME = '/app-dashboard';
export const DASHBOARD_BASENAME = '/dashboard';
export const DASHBOARD_ROOT_ROUTE = '/';
export const RESOURCEADM_BASENAME = '/resourceadm';
-export const STUDIO_LIBRARY_BASENAME = '/library';
+export const ORG_LIBRARY_BASENAME = '/org-library';
export const PREVIEW_BASENAME = '/preview';
export const STUDIO_ROOT_BASENAME = '/';
export const DEFAULT_LANGUAGE = 'nb';
diff --git a/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.test.ts
index 201486e2cc3..34b126c01a2 100644
--- a/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.test.ts
+++ b/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.test.ts
@@ -2,11 +2,11 @@ import { queriesMock } from 'app-shared/mocks/queriesMock';
import { app, org } from '@studio/testing/testids';
import { useAddOptionListMutation } from './useAddOptionListMutation';
import { renderHookWithProviders } from 'app-shared/mocks/renderHookWithProviders';
+import { FileUtils } from '@studio/pure-functions';
// Test data:
const file = new File(['hello'], 'hello.json', { type: 'text/json' });
-const formData = new FormData();
-formData.append('file', file);
+const formData = FileUtils.convertToFormData(file);
describe('useAddOptionsMutation', () => {
afterEach(jest.clearAllMocks);
diff --git a/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.ts
index 50ab147c1e5..fd77c4bb432 100644
--- a/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.ts
+++ b/frontend/packages/shared/src/hooks/mutations/useAddOptionListMutation.ts
@@ -2,13 +2,14 @@ import type { MutationMeta } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useServicesContext } from 'app-shared/contexts/ServicesContext';
import { QueryKey } from 'app-shared/types/QueryKey';
+import { FileUtils } from '@studio/pure-functions';
export const useAddOptionListMutation = (org: string, app: string, meta?: MutationMeta) => {
const queryClient = useQueryClient();
const { uploadOptionList } = useServicesContext();
const mutationFn = (file: File) => {
- const formData = createFormDataWithFile(file);
+ const formData = FileUtils.convertToFormData(file);
return uploadOptionList(org, app, formData);
};
@@ -21,9 +22,3 @@ export const useAddOptionListMutation = (org: string, app: string, meta?: Mutati
meta,
});
};
-
-const createFormDataWithFile = (file: File): FormData => {
- const formData = new FormData();
- formData.append('file', file);
- return formData;
-};
diff --git a/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.test.ts
new file mode 100644
index 00000000000..42b40bdd9b4
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.test.ts
@@ -0,0 +1,60 @@
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { org } from '@studio/testing/testids';
+import { queriesMock } from '../../mocks/queriesMock';
+import { useCreateOrgCodeListMutation } from './useCreateOrgCodeListMutation';
+import type { CodeList } from '../../types/CodeList';
+import type { CodeListData } from '../../types/CodeListData';
+import { createQueryClientMock } from '../../mocks/queryClientMock';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+
+// Test data:
+const codeList: CodeList = [
+ {
+ value: 'test-value',
+ label: 'test-label',
+ },
+];
+
+const newCodeList: CodeListData = {
+ title: 'new-title',
+ data: codeList,
+};
+
+const existingCodeList: CodeListData = {
+ title: 'existing-title',
+ data: codeList,
+};
+
+describe('useCreateOrgCodeListMutation', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('Calls createCodeListForOrg with correct parameters', async () => {
+ const { result } = renderHookWithProviders(() => useCreateOrgCodeListMutation(org));
+
+ await result.current.mutateAsync(newCodeList);
+
+ expect(queriesMock.createCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.createCodeListForOrg).toHaveBeenCalledWith(
+ org,
+ newCodeList.title,
+ newCodeList.data,
+ );
+ });
+
+ it('Replaces cache with api response', async () => {
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], [existingCodeList]);
+ const createCodeListForOrg = jest.fn(() => Promise.resolve([existingCodeList, newCodeList]));
+ const { result } = renderHookWithProviders(() => useCreateOrgCodeListMutation(org), {
+ queryClient,
+ queries: { createCodeListForOrg },
+ });
+
+ await result.current.mutateAsync(newCodeList);
+
+ const expectedUpdatedData: CodeListsResponse = [existingCodeList, newCodeList];
+ const updatedData = queryClient.getQueryData([QueryKey.OrgCodeLists, org]);
+ expect(updatedData).toEqual(expectedUpdatedData);
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.ts
new file mode 100644
index 00000000000..58753abcbb5
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useCreateOrgCodeListMutation.ts
@@ -0,0 +1,22 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+import type { CodeListData } from '../../types/CodeListData';
+
+type CreateOrgCodeListMutationArgs = Pick;
+
+export const useCreateOrgCodeListMutation = (org: string) => {
+ const queryClient = useQueryClient();
+ const { createCodeListForOrg } = useServicesContext();
+
+ const mutationFn = ({ title, data }: CreateOrgCodeListMutationArgs) =>
+ createCodeListForOrg(org, title, data);
+
+ return useMutation({
+ mutationFn,
+ onSuccess: (newData: CodeListsResponse) => {
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], newData);
+ },
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.test.ts
new file mode 100644
index 00000000000..06b084a5c47
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.test.ts
@@ -0,0 +1,23 @@
+import { queriesMock } from '../../mocks/queriesMock';
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { useCreateTextResourcesForOrgMutation } from './useCreateTextResourcesForOrgMutation';
+import { waitFor } from '@testing-library/react';
+import { org } from '@studio/testing/testids';
+
+const languageMock: string = 'nb';
+
+describe('useCreateTextResourcesForOrgMutation', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('Calls createTextResourcesForOrg with correct arguments and payload', async () => {
+ const { result } = renderHookWithProviders(() =>
+ useCreateTextResourcesForOrgMutation(org, languageMock),
+ );
+
+ result.current.mutate();
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(queriesMock.createTextResourcesForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.createTextResourcesForOrg).toHaveBeenCalledWith(org, languageMock);
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.ts b/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.ts
new file mode 100644
index 00000000000..5ea73f058f8
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useCreateTextResourcesForOrgMutation.ts
@@ -0,0 +1,19 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import { type ITextResourcesWithLanguage } from '../../types/global';
+
+export const useCreateTextResourcesForOrgMutation = (org: string, language: string) => {
+ const q = useQueryClient();
+ const { createTextResourcesForOrg } = useServicesContext();
+ return useMutation({
+ mutationFn: async () => {
+ const textResourcesWithLanguage: ITextResourcesWithLanguage[] =
+ await createTextResourcesForOrg(org, language);
+
+ return textResourcesWithLanguage;
+ },
+ onSuccess: (textResourcesWithLanguage) =>
+ q.setQueryData([QueryKey.TextResourcesForOrg, org], textResourcesWithLanguage),
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.test.ts
new file mode 100644
index 00000000000..2d3e05a11b4
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.test.ts
@@ -0,0 +1,56 @@
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { org } from '@studio/testing/testids';
+import { queriesMock } from '../../mocks/queriesMock';
+import { useDeleteOrgCodeListMutation } from '../../hooks/mutations/useDeleteOrgCodeListMutation';
+import { createQueryClientMock } from '../../mocks/queryClientMock';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+import type { CodeListData } from '../../types/CodeListData';
+import type { CodeList } from '../../types/CodeList';
+
+// Test data:
+const title = 'testId';
+
+const codeList: CodeList = [
+ {
+ value: 'test-value',
+ label: 'test-label',
+ },
+];
+
+const codeListToDelete: CodeListData = {
+ title: 'deleted-title',
+ data: codeList,
+};
+
+const otherCodeList: CodeListData = {
+ title: 'other-title',
+ data: codeList,
+};
+
+describe('useDeleteOrgCodeListMutation', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('Calls deleteCodeListForOrg with correct parameters', async () => {
+ const { result } = renderHookWithProviders(() => useDeleteOrgCodeListMutation(org));
+ await result.current.mutateAsync(title);
+ expect(queriesMock.deleteCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.deleteCodeListForOrg).toHaveBeenCalledWith(org, title);
+ });
+
+ it('Replaces cache with api response', async () => {
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], [codeListToDelete, otherCodeList]);
+ const deleteCodeListForOrg = jest.fn(() => Promise.resolve([otherCodeList]));
+ const { result } = renderHookWithProviders(() => useDeleteOrgCodeListMutation(org), {
+ queryClient,
+ queries: { deleteCodeListForOrg },
+ });
+
+ await result.current.mutateAsync({ title: codeListToDelete.title });
+
+ const expectedUpdatedData: CodeListsResponse = [otherCodeList];
+ const updatedData = queryClient.getQueryData([QueryKey.OrgCodeLists, org]);
+ expect(updatedData).toEqual(expectedUpdatedData);
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.ts
new file mode 100644
index 00000000000..003a0b146f7
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useDeleteOrgCodeListMutation.ts
@@ -0,0 +1,18 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+
+export const useDeleteOrgCodeListMutation = (org: string) => {
+ const queryClient = useQueryClient();
+ const { deleteCodeListForOrg } = useServicesContext();
+
+ const mutationFn = (title: string) => deleteCodeListForOrg(org, title);
+
+ return useMutation({
+ mutationFn,
+ onSuccess: (newData: CodeListsResponse) => {
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], newData);
+ },
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.test.ts
new file mode 100644
index 00000000000..9e89cd5d4d4
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.test.ts
@@ -0,0 +1,58 @@
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { org } from '@studio/testing/testids';
+import { queriesMock } from '../../mocks/queriesMock';
+import type { CodeList } from '../../types/CodeList';
+import { useUpdateOrgCodeListMutation } from './useUpdateOrgCodeListMutation';
+import { createQueryClientMock } from '../../mocks/queryClientMock';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+import type { CodeListData } from '../../types/CodeListData';
+
+// Test data:
+const codeList: CodeList = [
+ {
+ value: 'test-value',
+ label: 'test-label',
+ },
+];
+
+const oldCodeList: CodeListData = {
+ title: 'old-title',
+ data: codeList,
+};
+
+const updatedCodeList: CodeListData = {
+ title: 'updated-title',
+ data: codeList,
+};
+
+describe('useUpdateOrgCodeListMutation', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('Calls updateCodeListForOrg with correct parameters', async () => {
+ const { result } = renderHookWithProviders(() => useUpdateOrgCodeListMutation(org));
+ await result.current.mutateAsync(updatedCodeList);
+ expect(queriesMock.updateCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.updateCodeListForOrg).toHaveBeenCalledWith(
+ org,
+ updatedCodeList.title,
+ updatedCodeList.data,
+ );
+ });
+
+ it('Replaces cache with api response', async () => {
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], [oldCodeList]);
+ const updateCodeListForOrg = jest.fn(() => Promise.resolve([updatedCodeList]));
+ const { result } = renderHookWithProviders(() => useUpdateOrgCodeListMutation(org), {
+ queryClient,
+ queries: { updateCodeListForOrg },
+ });
+
+ await result.current.mutateAsync(updatedCodeList);
+
+ const expectedUpdatedData: CodeListsResponse = [updatedCodeList];
+ const updatedData = queryClient.getQueryData([QueryKey.OrgCodeLists, org]);
+ expect(updatedData).toEqual(expectedUpdatedData);
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.ts
new file mode 100644
index 00000000000..5c397a0b356
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUpdateOrgCodeListMutation.ts
@@ -0,0 +1,22 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListData } from '../../types/CodeListData';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+
+type UpdateOrgCodeListMutationArgs = Pick;
+
+export const useUpdateOrgCodeListMutation = (org: string) => {
+ const queryClient = useQueryClient();
+ const { updateCodeListForOrg } = useServicesContext();
+
+ const mutationFn = ({ title, data }: UpdateOrgCodeListMutationArgs) =>
+ updateCodeListForOrg(org, title, data);
+
+ return useMutation({
+ mutationFn,
+ onSuccess: (newData: CodeListsResponse) => {
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], newData);
+ },
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.test.ts
new file mode 100644
index 00000000000..b3c60792e6d
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.test.ts
@@ -0,0 +1,28 @@
+import { queriesMock } from '../../mocks/queriesMock';
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { useUpdateTextResourcesForOrgMutation } from './useUpdateTextResourcesForOrgMutation';
+import { waitFor } from '@testing-library/react';
+import { org } from '@studio/testing/testids';
+import { type ITextResource } from '../../types/global';
+import { label1TextResource, label2TextResource } from '../../mocks/textResourcesMock';
+
+const languageMock: string = 'nb';
+const textResourcesMock: ITextResource[] = [label1TextResource, label2TextResource];
+
+describe('useUpdateTextResourcesForOrgMutation', () => {
+ it('Calls updateTextResourcesForOrg with correct arguments and payload', async () => {
+ const { result } = renderHookWithProviders(() =>
+ useUpdateTextResourcesForOrgMutation(org, languageMock),
+ );
+
+ result.current.mutate(textResourcesMock);
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(queriesMock.updateTextResourcesForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.updateTextResourcesForOrg).toHaveBeenCalledWith(
+ org,
+ languageMock,
+ textResourcesMock,
+ );
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.ts
new file mode 100644
index 00000000000..255cdf41a3b
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUpdateTextResourcesForOrgMutation.ts
@@ -0,0 +1,19 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { type ITextResourcesWithLanguage, type ITextResource } from '../../types/global';
+import { QueryKey } from '../../types/QueryKey';
+
+export const useUpdateTextResourcesForOrgMutation = (org: string, language: string) => {
+ const q = useQueryClient();
+ const { updateTextResourcesForOrg } = useServicesContext();
+ return useMutation({
+ mutationFn: async (payload: ITextResource[]) => {
+ const textResourcesWithLanguage: ITextResourcesWithLanguage[] =
+ await updateTextResourcesForOrg(org, language, payload);
+
+ return textResourcesWithLanguage;
+ },
+ onSuccess: (textResourcesWithLanguage) =>
+ q.setQueryData([QueryKey.TextResourcesForOrg, org], textResourcesWithLanguage),
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.test.ts
new file mode 100644
index 00000000000..03badabc8d5
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.test.ts
@@ -0,0 +1,55 @@
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { org } from '@studio/testing/testids';
+import { queriesMock } from '../../mocks/queriesMock';
+import { useUploadOrgCodeListMutation } from './useUploadOrgCodeListMutation';
+import { createQueryClientMock } from '../../mocks/queryClientMock';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+import type { CodeList } from '../../types/CodeList';
+import { FileUtils } from '@studio/pure-functions';
+
+// Test data:
+const fileName = 'fileName';
+const fileData: CodeList = [
+ {
+ value: 'test-value',
+ label: 'test-label',
+ },
+];
+
+const jsonData = JSON.stringify(fileData);
+const file = new File([jsonData], `${fileName}.json`, { type: 'text/json' });
+const formData = FileUtils.convertToFormData(file);
+
+const codeListResponse: CodeListsResponse = [
+ {
+ title: fileName,
+ data: fileData,
+ },
+];
+
+describe('useUploadOrgCodeListMutation', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('Calls uploadCodeListForOrg with correct parameters', async () => {
+ const { result } = renderHookWithProviders(() => useUploadOrgCodeListMutation(org));
+ await result.current.mutateAsync(file);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledTimes(1);
+ expect(queriesMock.uploadCodeListForOrg).toHaveBeenCalledWith(org, formData);
+ });
+
+ it('Replaces cache with api response', async () => {
+ const queryClient = createQueryClientMock();
+ const uploadCodeListForOrg = jest.fn(() => Promise.resolve(codeListResponse));
+ const { result } = renderHookWithProviders(() => useUploadOrgCodeListMutation(org), {
+ queryClient,
+ queries: { uploadCodeListForOrg },
+ });
+
+ await result.current.mutateAsync(file);
+
+ const expectedUpdatedData: CodeListsResponse = codeListResponse;
+ const updatedData = queryClient.getQueryData([QueryKey.OrgCodeLists, org]);
+ expect(updatedData).toEqual(expectedUpdatedData);
+ });
+});
diff --git a/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.ts
new file mode 100644
index 00000000000..13197f015cb
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/mutations/useUploadOrgCodeListMutation.ts
@@ -0,0 +1,24 @@
+import type { MutationMeta } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+import { FileUtils } from '@studio/pure-functions';
+
+export const useUploadOrgCodeListMutation = (org: string, meta?: MutationMeta) => {
+ const queryClient = useQueryClient();
+ const { uploadCodeListForOrg } = useServicesContext();
+
+ const mutationFn = (file: File) => {
+ const formData = FileUtils.convertToFormData(file);
+ return uploadCodeListForOrg(org, formData);
+ };
+
+ return useMutation({
+ mutationFn,
+ onSuccess: (newData: CodeListsResponse) => {
+ queryClient.setQueryData([QueryKey.OrgCodeLists, org], newData);
+ },
+ meta,
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.test.ts b/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.test.ts
new file mode 100644
index 00000000000..5e1324de43f
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.test.ts
@@ -0,0 +1,18 @@
+import { waitFor } from '@testing-library/react';
+import { queriesMock } from '../../mocks/queriesMock';
+import { renderHookWithProviders } from '../../mocks/renderHookWithProviders';
+import { org } from '@studio/testing/testids';
+import { useOrgCodeListsQuery } from '../../hooks/queries/useOrgCodeListsQuery';
+
+describe('useOrgCodeListsQuery', () => {
+ it('calls getCodeListsForOrg with the correct parameters', () => {
+ render();
+ expect(queriesMock.getCodeListsForOrg).toHaveBeenCalledWith(org);
+ });
+});
+
+const render = async () => {
+ const { result } = renderHookWithProviders(() => useOrgCodeListsQuery(org));
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ return result;
+};
diff --git a/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.ts b/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.ts
new file mode 100644
index 00000000000..0a180a244ef
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/queries/useOrgCodeListsQuery.ts
@@ -0,0 +1,13 @@
+import { useServicesContext } from 'app-shared/contexts/ServicesContext';
+import { QueryKey } from '../../types/QueryKey';
+import type { UseQueryResult } from '@tanstack/react-query';
+import { useQuery } from '@tanstack/react-query';
+import type { CodeListsResponse } from '../../types/api/CodeListsResponse';
+
+export const useOrgCodeListsQuery = (org: string): UseQueryResult => {
+ const { getCodeListsForOrg } = useServicesContext();
+ return useQuery({
+ queryKey: [QueryKey.OrgCodeLists, org],
+ queryFn: () => getCodeListsForOrg(org),
+ });
+};
diff --git a/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.test.ts b/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.test.ts
new file mode 100644
index 00000000000..db8e32d6324
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.test.ts
@@ -0,0 +1,22 @@
+import { queriesMock } from 'app-shared/mocks/queriesMock';
+import { renderHookWithProviders } from 'app-shared/mocks/renderHookWithProviders';
+import { waitFor } from '@testing-library/react';
+import { org } from '@studio/testing/testids';
+import { useTextResourcesForOrgQuery } from './useTextResourcesForOrgQuery';
+
+const languageMock: string = 'nb';
+
+describe('useTextResourcesForOrgQuery', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('calls getTextResourcesForOrg with the correct parameters', () => {
+ renderAndWaitForResult();
+ expect(queriesMock.getTextResourcesForOrg).toHaveBeenCalledWith(org, languageMock);
+ expect(queriesMock.getTextResourcesForOrg).toHaveBeenCalledTimes(1);
+ });
+});
+
+const renderAndWaitForResult = async (): Promise => {
+ const { result } = renderHookWithProviders(() => useTextResourcesForOrgQuery(org, languageMock));
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+};
diff --git a/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.ts b/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.ts
new file mode 100644
index 00000000000..8cf4e283aca
--- /dev/null
+++ b/frontend/packages/shared/src/hooks/queries/useTextResourcesForOrgQuery.ts
@@ -0,0 +1,15 @@
+import { useQuery, type UseQueryResult } from '@tanstack/react-query';
+import { QueryKey } from '../../types/QueryKey';
+import { useServicesContext } from '../../contexts/ServicesContext';
+import { type ITextResourcesWithLanguage } from '../../types/global';
+
+export const useTextResourcesForOrgQuery = (
+ org: string,
+ language: string,
+): UseQueryResult => {
+ const { getTextResourcesForOrg } = useServicesContext();
+ return useQuery({
+ queryKey: [QueryKey.TextResourcesForOrg],
+ queryFn: () => getTextResourcesForOrg(org, language),
+ });
+};
diff --git a/frontend/packages/shared/src/mocks/codeListsResponse.ts b/frontend/packages/shared/src/mocks/codeListsResponse.ts
new file mode 100644
index 00000000000..63a9a94ef81
--- /dev/null
+++ b/frontend/packages/shared/src/mocks/codeListsResponse.ts
@@ -0,0 +1,47 @@
+import type { CodeListsResponse } from '../types/api/CodeListsResponse';
+import type { CodeListData } from '@studio/content-library';
+import {
+ description1TextResource,
+ description2TextResource,
+ helpText1TextResource,
+ helpText2TextResource,
+ label1TextResource,
+ label2TextResource,
+ label3TextResource,
+ label4TextResource,
+ label5TextResource,
+} from 'app-shared/mocks/textResourcesMock';
+
+const codeList1: CodeListData = {
+ title: 'codeList1',
+ data: [
+ {
+ description: description1TextResource.id,
+ helpText: helpText1TextResource.id,
+ label: label1TextResource.id,
+ value: 'item1',
+ },
+ {
+ description: description2TextResource.id,
+ helpText: helpText2TextResource.id,
+ label: label2TextResource.id,
+ value: 'item2',
+ },
+ ],
+};
+
+const codeList2: CodeListData = {
+ title: 'codeList2',
+ data: [
+ { label: label3TextResource.id, value: 'a' },
+ { label: label4TextResource.id, value: 'b' },
+ { label: label5TextResource.id, value: 'c' },
+ ],
+};
+
+const codeListWithError: CodeListData = {
+ title: 'codeListWithError',
+ hasError: true,
+};
+
+export const codeListsResponse: CodeListsResponse = [codeList1, codeList2, codeListWithError];
diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts
index f230ea80b12..7bd42b3daa4 100644
--- a/frontend/packages/shared/src/mocks/queriesMock.ts
+++ b/frontend/packages/shared/src/mocks/queriesMock.ts
@@ -74,6 +74,7 @@ import type { OptionListReferences } from 'app-shared/types/OptionListReferences
import type { LayoutSetsModel } from '../types/api/dto/LayoutSetsModel';
import { layoutSetsExtendedMock } from '@altinn/ux-editor/testing/layoutSetsMock';
import type { OptionListsResponse } from 'app-shared/types/api/OptionListsResponse';
+import type { CodeListsResponse } from 'app-shared/types/api/CodeListsResponse';
export const queriesMock: ServicesContextProps = {
// Queries
@@ -83,6 +84,7 @@ export const queriesMock: ServicesContextProps = {
.mockImplementation(() => Promise.resolve(appReleasesResponse)),
getAppVersion: jest.fn().mockImplementation(() => Promise.resolve(appVersion)),
getBranchStatus: jest.fn().mockImplementation(() => Promise.resolve(branchStatus)),
+ getCodeListsForOrg: jest.fn().mockImplementation(() => Promise.resolve([])),
getDataModel: jest.fn().mockImplementation(() => Promise.resolve({})),
getDataModelMetadata: jest
.fn()
@@ -196,6 +198,11 @@ export const queriesMock: ServicesContextProps = {
getSelectedMaskinportenScopes: jest
.fn()
.mockImplementation(() => Promise.resolve([])),
+ getTextResourcesForOrg: jest
+ .fn()
+ .mockImplementation(() =>
+ Promise.resolve(textResourcesWithLanguage),
+ ),
updateSelectedMaskinportenScopes: jest.fn().mockImplementation(() => Promise.resolve()),
// Queries - Contact
@@ -215,6 +222,7 @@ export const queriesMock: ServicesContextProps = {
.fn()
.mockImplementation(() => Promise.resolve(createRepoCommitPayload)),
copyApp: jest.fn().mockImplementation(() => Promise.resolve()),
+ createCodeListForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
createDataModel: jest.fn().mockImplementation(() => Promise.resolve({})),
updateDataType: jest.fn().mockImplementation(() => Promise.resolve({})),
createDeployment: jest.fn().mockImplementation(() => Promise.resolve()),
@@ -222,7 +230,9 @@ export const queriesMock: ServicesContextProps = {
createRepoCommit: jest
.fn()
.mockImplementation(() => Promise.resolve(createRepoCommitPayload)),
+ createTextResourcesForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
deleteAppAttachmentMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
+ deleteCodeListForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
deleteDataModel: jest.fn().mockImplementation(() => Promise.resolve()),
deleteDataTypeFromAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
deleteFormLayout: jest.fn().mockImplementation(() => Promise.resolve()),
@@ -249,8 +259,11 @@ export const queriesMock: ServicesContextProps = {
updateAppPolicy: jest.fn().mockImplementation(() => Promise.resolve()),
updateAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
updateAppConfig: jest.fn().mockImplementation(() => Promise.resolve()),
+ updateCodeListForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
updateOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
updateOptionListId: jest.fn().mockImplementation(() => Promise.resolve()),
+ updateTextResourcesForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
+ uploadCodeListForOrg: jest.fn().mockImplementation(() => Promise.resolve()),
uploadDataModel: jest.fn().mockImplementation(() => Promise.resolve({})),
uploadOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
upsertTextResources: jest
diff --git a/frontend/packages/shared/src/mocks/textResourcesMock.ts b/frontend/packages/shared/src/mocks/textResourcesMock.ts
new file mode 100644
index 00000000000..422d756b113
--- /dev/null
+++ b/frontend/packages/shared/src/mocks/textResourcesMock.ts
@@ -0,0 +1,87 @@
+import type { ITextResource, ITextResourcesWithLanguage } from 'app-shared/types/global';
+
+export const label1TextResource: ITextResource = {
+ id: 'label1',
+ value: 'Ledetekst 1',
+};
+export const label2TextResource: ITextResource = {
+ id: 'label2',
+ value: 'Ledetekst 2',
+};
+export const label3TextResource: ITextResource = {
+ id: 'label3',
+ value: 'Ledetekst 3',
+};
+export const label4TextResource: ITextResource = {
+ id: 'label4',
+ value: 'Ledetekst 4',
+};
+export const label5TextResource: ITextResource = {
+ id: 'label5',
+ value: 'Ledetekst 5',
+};
+
+export const description1TextResource: ITextResource = {
+ id: 'description1',
+ value: 'Beskrivelse 1',
+};
+export const description2TextResource: ITextResource = {
+ id: 'description2',
+ value: 'Beskrivelse 2',
+};
+export const description3TextResource: ITextResource = {
+ id: 'description3',
+ value: 'Beskrivelse 3',
+};
+export const description4TextResource: ITextResource = {
+ id: 'description4',
+ value: 'Beskrivelse 4',
+};
+export const description5TextResource: ITextResource = {
+ id: 'description5',
+ value: 'Beskrivelse 5',
+};
+
+export const helpText1TextResource: ITextResource = {
+ id: 'helpText1',
+ value: 'Hjelpetekst 1',
+};
+export const helpText2TextResource: ITextResource = {
+ id: 'helpText2',
+ value: 'Hjelpetekst 2',
+};
+export const helpText3TextResource: ITextResource = {
+ id: 'helpText3',
+ value: 'Hjelpetekst 3',
+};
+export const helpText4TextResource: ITextResource = {
+ id: 'helpText4',
+ value: 'Hjelpetekst 4',
+};
+export const helpText5TextResource: ITextResource = {
+ id: 'helpText5',
+ value: 'Hjelpetekst 5',
+};
+
+const textResources: ITextResource[] = [
+ label1TextResource,
+ label2TextResource,
+ label3TextResource,
+ label4TextResource,
+ label5TextResource,
+ description1TextResource,
+ description2TextResource,
+ description3TextResource,
+ description4TextResource,
+ description5TextResource,
+ helpText1TextResource,
+ helpText2TextResource,
+ helpText3TextResource,
+ helpText4TextResource,
+ helpText5TextResource,
+];
+
+export const textResourcesMock: ITextResourcesWithLanguage = {
+ language: 'nb',
+ resources: textResources,
+};
diff --git a/frontend/packages/shared/src/types/CodeList.ts b/frontend/packages/shared/src/types/CodeList.ts
new file mode 100644
index 00000000000..bd336bfa537
--- /dev/null
+++ b/frontend/packages/shared/src/types/CodeList.ts
@@ -0,0 +1 @@
+export type { CodeList } from '@studio/components';
diff --git a/frontend/packages/shared/src/types/CodeListData.ts b/frontend/packages/shared/src/types/CodeListData.ts
new file mode 100644
index 00000000000..d09e909f0c4
--- /dev/null
+++ b/frontend/packages/shared/src/types/CodeListData.ts
@@ -0,0 +1,7 @@
+import type { CodeListItem } from '@studio/components';
+
+export type CodeListData = {
+ title: string;
+ data?: CodeListItem[];
+ hasError?: boolean;
+};
diff --git a/frontend/packages/shared/src/types/QueryKey.ts b/frontend/packages/shared/src/types/QueryKey.ts
index 42563eec16c..3e9c6e04687 100644
--- a/frontend/packages/shared/src/types/QueryKey.ts
+++ b/frontend/packages/shared/src/types/QueryKey.ts
@@ -30,6 +30,7 @@ export enum QueryKey {
OptionListsUsage = 'OptionListsUsage',
OptionLists = 'OptionLists',
OptionListIds = 'OptionListIds',
+ OrgCodeLists = 'OrgCodeLists',
OrgList = 'OrgList',
Organizations = 'Organizations',
ProcessTaskDataType = 'ProcessTaskDataType',
@@ -50,6 +51,7 @@ export enum QueryKey {
SelectedAppScopes = 'SelectedAppScopes',
UserOrgPermissions = 'UserOrgPermissions',
DataType = 'DataType',
+ TextResourcesForOrg = 'TextResourcesForOrg',
Pages = 'Pages',
// Resourceadm
diff --git a/frontend/packages/shared/src/types/api/CodeListsResponse.ts b/frontend/packages/shared/src/types/api/CodeListsResponse.ts
new file mode 100644
index 00000000000..875fa3b26dd
--- /dev/null
+++ b/frontend/packages/shared/src/types/api/CodeListsResponse.ts
@@ -0,0 +1,3 @@
+import type { CodeListData } from '../CodeListData';
+
+export type CodeListsResponse = CodeListData[];
diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts
index defafe43ee5..98a84eb2581 100644
--- a/frontend/packages/shared/src/utils/featureToggleUtils.ts
+++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts
@@ -10,6 +10,7 @@ export enum FeatureFlag {
Maskinporten = 'maskinporten',
MainConfig = 'mainConfig',
OptionListEditor = 'optionListEditor',
+ OrgLibrary = 'orgLibrary',
ShouldOverrideAppLibCheck = 'shouldOverrideAppLibCheck',
TaskNavigation = 'taskNavigation',
}
diff --git a/frontend/testing/playwright/components/DashboardHeader.ts b/frontend/testing/playwright/components/DashboardHeader.ts
new file mode 100644
index 00000000000..4249c6a54be
--- /dev/null
+++ b/frontend/testing/playwright/components/DashboardHeader.ts
@@ -0,0 +1,17 @@
+import { BasePage } from '../helpers/BasePage';
+import type { Environment } from '../helpers/StudioEnvironment';
+import type { Page } from '@playwright/test';
+
+type TopMenuName = 'dashboard' | 'library';
+
+export class DashboardHeader extends BasePage {
+ constructor(page: Page, environment?: Environment) {
+ super(page, environment);
+ }
+
+ public async clickOnNavigateToPageInTopMenuHeader(menuName: TopMenuName): Promise {
+ await this.page
+ .getByRole('link', { name: this.textMock(`dashboard.header_item_${menuName}`) })
+ .click();
+ }
+}
diff --git a/frontend/testing/playwright/enum/AppNames.ts b/frontend/testing/playwright/enum/AppNames.ts
index 2e289eed1ac..23085cdd5aa 100644
--- a/frontend/testing/playwright/enum/AppNames.ts
+++ b/frontend/testing/playwright/enum/AppNames.ts
@@ -8,4 +8,5 @@ export enum AppNames {
UI_EDITOR_APP = 'ui-editor-app-test',
TEXT_EDITOR_APP = 'text-editor-app-test',
PROCESS_EDITOR_APP = 'process-editor-app-test',
+ ORG_LIBRARY = 'org-library-app-test',
}
diff --git a/frontend/testing/playwright/enum/TestNames.ts b/frontend/testing/playwright/enum/TestNames.ts
index 29518cdb97d..fcd051b0653 100644
--- a/frontend/testing/playwright/enum/TestNames.ts
+++ b/frontend/testing/playwright/enum/TestNames.ts
@@ -11,4 +11,5 @@ export enum TestNames {
SETTINGS_MODAL = 'settings-modal',
TEXT_EDITOR = 'text-editor',
PROCESS_EDITOR = 'process-editor',
+ ORG_LIBRARY = 'org-library',
}
diff --git a/frontend/testing/playwright/helpers/RouterRoute.ts b/frontend/testing/playwright/helpers/RouterRoute.ts
index 04a354ca238..8c29511d37b 100644
--- a/frontend/testing/playwright/helpers/RouterRoute.ts
+++ b/frontend/testing/playwright/helpers/RouterRoute.ts
@@ -5,6 +5,8 @@ type SupportedRoutes =
| 'altinnLoginPage'
| 'dashboard'
| 'dashboardCreateApp'
+ | 'dashboardAsOrg'
+ | 'orgLibrary'
| 'deploy'
| 'editorOverview'
| 'editorDataModel'
@@ -18,8 +20,10 @@ type RouterRoutes = Record;
const routerRoutes: RouterRoutes = {
altinnLoginPage: '/',
- dashboard: '/dashboard/self',
- dashboardCreateApp: '/dashboard/self/new',
+ dashboard: '/dashboard/app-dashboard/self',
+ dashboardCreateApp: '/dashboard/app-dashboard/self/new',
+ dashboardAsOrg: '/dashboard/app-dashboard/{{org}}',
+ orgLibrary: '/dashboard/org-library/{{org}}',
deploy: '/editor/{{org}}/{{app}}/deploy',
editorOverview: '/editor/{{org}}/{{app}}/overview',
editorDataModel: '/editor/{{org}}/{{app}}/data-model',
diff --git a/frontend/testing/playwright/pages/DashboardPage.ts b/frontend/testing/playwright/pages/DashboardPage.ts
index b28f3417140..65e04f27cfe 100644
--- a/frontend/testing/playwright/pages/DashboardPage.ts
+++ b/frontend/testing/playwright/pages/DashboardPage.ts
@@ -11,10 +11,20 @@ export class DashboardPage extends BasePage {
await this.page.goto(this.getRoute('dashboard'));
}
+ public async loadDashboardPageAsOrg(featureFlag?: string): Promise {
+ const featureFlagParam: string = featureFlag ?? '';
+ await this.page.goto(this.getRoute('dashboardAsOrg') + featureFlagParam);
+ }
+
public async verifyDashboardPage(): Promise {
await this.page.waitForURL(this.getRoute('dashboard'));
}
+ public async verifyDashboardPageAsOrg(featureFlag?: string): Promise {
+ const featureFlagParam: string = featureFlag ?? '';
+ await this.page.waitForURL(this.getRoute('dashboardAsOrg') + featureFlagParam);
+ }
+
public async clickOnCreateAppLink(): Promise {
await this.page.getByRole('link', { name: this.textMock('dashboard.new_service') }).click();
}
diff --git a/frontend/testing/playwright/pages/OrgLibraryPage/CodeLists.ts b/frontend/testing/playwright/pages/OrgLibraryPage/CodeLists.ts
new file mode 100644
index 00000000000..130f8ba625b
--- /dev/null
+++ b/frontend/testing/playwright/pages/OrgLibraryPage/CodeLists.ts
@@ -0,0 +1,212 @@
+import { BasePage } from '../../helpers/BasePage';
+import { expect, type Page } from '@playwright/test';
+import path from 'path';
+
+export class CodeLists extends BasePage {
+ constructor(public page: Page) {
+ super(page);
+ }
+
+ public async waitForCodeListPageToLoad(): Promise {
+ const heading = this.page.getByRole('heading', {
+ name: this.textMock('app_content_library.code_lists.page_name'),
+ level: 1,
+ exact: true,
+ });
+
+ await expect(heading).toBeVisible();
+ }
+
+ public async clickOnCreateNewCodelistButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('app_content_library.code_lists.create_new_code_list'),
+ })
+ .click();
+ }
+
+ public async verifyNewCodelistModalIsOpen(): Promise {
+ const modalTitle = this.page.getByRole('heading', {
+ name: this.textMock('app_content_library.code_lists.create_new_code_list_modal_title'),
+ level: 2,
+ });
+
+ await expect(modalTitle).toBeVisible({ timeout: 8000 });
+ }
+
+ public async writeCodelistTitle(title: string): Promise {
+ await this.page
+ .getByRole('textbox', {
+ name: this.textMock('app_content_library.code_lists.create_new_code_list_name'),
+ })
+ .fill(title);
+ }
+
+ public async clickOnAddAlternativeButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('code_list_editor.add_option'),
+ })
+ .click();
+ }
+
+ public async verifyNewItemValueFieldIsVisible(itemNumber: number): Promise {
+ const newItemValueField = this.page.getByRole('textbox', {
+ name: this.textMock('code_list_editor.value_item', { number: itemNumber.toString() }),
+ exact: true,
+ });
+
+ await expect(newItemValueField).toBeVisible();
+ }
+
+ public async writeCodelistValue(itemNumber: number, value: string): Promise {
+ await this.page
+ .getByRole('textbox', {
+ name: this.textMock('code_list_editor.value_item', { number: itemNumber.toString() }),
+ exact: true,
+ })
+ .fill(value);
+ }
+
+ public async writeCodelistLabel(itemNumber: number, label: string): Promise {
+ await this.page
+ .getByRole('textbox', {
+ name: this.textMock('code_list_editor.label_item', { number: itemNumber.toString() }),
+ })
+ .fill(label);
+ }
+
+ public async clickOnSaveCodelistButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('app_content_library.code_lists.save_new_code_list'),
+ })
+ .click();
+ }
+
+ public async verifyThatCodeListIsVisible(title: string): Promise {
+ const codeList = this.page.getByTitle(
+ this.textMock('app_content_library.code_lists.code_list_accordion_title', {
+ codeListTitle: title,
+ }),
+ );
+
+ await expect(codeList).toBeVisible();
+ }
+
+ public async clickOnAddItemButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('code_list_editor.add_option'),
+ })
+ .click();
+ }
+
+ public async clickOnCodeListAccordion(codeListTitle: string): Promise {
+ await this.page.getByRole('heading', { name: codeListTitle }).click();
+ }
+
+ public async typeInSearchBox(searchTerm: string): Promise {
+ await this.page
+ .getByRole('searchbox', {
+ name: this.textMock('app_content_library.code_lists.search_label'),
+ })
+ .fill(searchTerm);
+ }
+
+ public async clickOnDeleteCodelistButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('app_content_library.code_lists.code_list_delete'),
+ })
+ .click();
+ }
+
+ public async verifyEmptyValueTextfield(itemNumber: number): Promise {
+ await this.verifyValueTextfield(itemNumber, '');
+ }
+
+ public async verifyEmptyLabelTextfield(itemNumber: number): Promise {
+ await this.verifyLabelTextfield(itemNumber, '');
+ }
+
+ public async verifyValueTextfield(itemNumber: number, value: string): Promise {
+ const textfield = this.page.getByRole('textbox', {
+ name: this.textMock(`code_list_editor.value_item`, { number: itemNumber.toString() }),
+ exact: true,
+ });
+
+ await expect(textfield).toHaveValue(value);
+ }
+
+ public async verifyLabelTextfield(itemNumber: number, value: string): Promise {
+ const textfield = this.page.getByRole('textbox', {
+ name: this.textMock(`code_list_editor.label_item`, { number: itemNumber.toString() }),
+ exact: true,
+ });
+
+ await expect(textfield).toHaveValue(value);
+ }
+
+ public async clickOnUploadButtonAndSelectFileToUpload(fileName: string): Promise {
+ const fileChooserPromise = this.page.waitForEvent('filechooser');
+
+ await this.clickOnUploadCodelistButton();
+
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(path.join(__dirname, fileName));
+ }
+
+ public async listenToAndWaitForConfirmDeleteCodeList(codeListTitle: string): Promise {
+ this.page.once('dialog', async (dialog) => {
+ expect(dialog.type()).toBe('confirm');
+ expect(dialog.message()).toContain(
+ this.textMock('app_content_library.code_lists.code_list_delete_confirm', { codeListTitle }),
+ );
+ await dialog.accept();
+ });
+ }
+
+ public async verifyThatCodeListIsNotVisible(title: string): Promise {
+ const codeList = this.page.getByTitle(
+ this.textMock('app_content_library.code_lists.code_list_accordion_title', {
+ codeListTitle: title,
+ }),
+ );
+
+ await expect(codeList).toBeHidden();
+ }
+
+ public async verifyNumberOfItemsInTheCodelist(
+ numberOfItems: number,
+ codeListTitle: string,
+ ): Promise {
+ const accordionTitle = this.page.getByRole('heading', { name: codeListTitle });
+ const accordion = accordionTitle.locator('xpath=..');
+ const table = accordion.getByRole('table');
+ const rows = table.getByRole('row');
+
+ const headerRow: number = 1;
+ const totalNumberOfRows: number = numberOfItems + headerRow;
+
+ await expect(rows).toHaveCount(totalNumberOfRows);
+ }
+
+ public async clickOnDeleteItemButton(itemNumber: number): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('code_list_editor.delete_code_list_item', {
+ number: itemNumber.toString(),
+ }),
+ })
+ .click();
+ }
+
+ private async clickOnUploadCodelistButton(): Promise {
+ await this.page
+ .getByRole('button', {
+ name: this.textMock('app_content_library.code_lists.upload_code_list'),
+ })
+ .click();
+ }
+}
diff --git a/frontend/testing/playwright/pages/OrgLibraryPage/OrgLibraryPage.ts b/frontend/testing/playwright/pages/OrgLibraryPage/OrgLibraryPage.ts
new file mode 100644
index 00000000000..f4b5d41ce93
--- /dev/null
+++ b/frontend/testing/playwright/pages/OrgLibraryPage/OrgLibraryPage.ts
@@ -0,0 +1,38 @@
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { BasePage } from '../../helpers/BasePage';
+import type { Environment } from '../../helpers/StudioEnvironment';
+import { CodeLists } from './CodeLists';
+
+export class OrgLibraryPage extends BasePage {
+ public readonly codeLists: CodeLists;
+
+ constructor(page: Page, environment?: Environment) {
+ super(page, environment);
+ this.codeLists = new CodeLists(page);
+ }
+
+ public async loadOrgLibraryPage(): Promise {
+ await this.page.goto(this.getRoute('orgLibrary'));
+ }
+
+ public async verifyOrgLibraryPage(): Promise {
+ await this.page.waitForURL(this.getRoute('orgLibrary'));
+ }
+
+ public async waitForPageHeaderToBeVisible(): Promise {
+ const heading = this.page.getByRole('heading', {
+ name: this.textMock('app_content_library.library_heading'),
+ level: 1,
+ exact: true,
+ });
+
+ await expect(heading).toBeVisible();
+ }
+
+ public async clickOnNavigateToCodeListPage(): Promise {
+ await this.page
+ .getByRole('tab', { name: this.textMock('app_content_library.code_lists.page_name') })
+ .click();
+ }
+}
diff --git a/frontend/testing/playwright/pages/OrgLibraryPage/index.ts b/frontend/testing/playwright/pages/OrgLibraryPage/index.ts
new file mode 100644
index 00000000000..9422c4b787b
--- /dev/null
+++ b/frontend/testing/playwright/pages/OrgLibraryPage/index.ts
@@ -0,0 +1 @@
+export { OrgLibraryPage } from './OrgLibraryPage';
diff --git a/frontend/testing/playwright/pages/OrgLibraryPage/testCodelist.json b/frontend/testing/playwright/pages/OrgLibraryPage/testCodelist.json
new file mode 100644
index 00000000000..4663f2cd33c
--- /dev/null
+++ b/frontend/testing/playwright/pages/OrgLibraryPage/testCodelist.json
@@ -0,0 +1,5 @@
+[
+ { "value": "test1", "label": "test1" },
+ { "value": "test2", "label": "test2" },
+ { "value": "test3", "label": "test3" }
+]
diff --git a/frontend/testing/playwright/playwright.config.ts b/frontend/testing/playwright/playwright.config.ts
index 35416cdfb59..e28105184ab 100644
--- a/frontend/testing/playwright/playwright.config.ts
+++ b/frontend/testing/playwright/playwright.config.ts
@@ -132,6 +132,19 @@ export default defineConfig({
headless: true,
},
},
+ {
+ name: TestNames.ORG_LIBRARY,
+ dependencies: [TestNames.SETUP],
+ testDir: './tests/org-library/',
+ testMatch: '*.spec.ts',
+ timeout: 60000,
+ use: {
+ ...devices['Desktop Chrome'],
+ storageState: '.playwright/auth/user.json',
+ testAppName: AppNames.ORG_LIBRARY,
+ headless: true,
+ },
+ },
{
name: TestNames.LOGOUT,
dependencies: [
@@ -145,6 +158,7 @@ export default defineConfig({
TestNames.SETTINGS_MODAL,
TestNames.TEXT_EDITOR,
TestNames.PROCESS_EDITOR,
+ TestNames.ORG_LIBRARY,
],
testDir: './tests/logout/',
testMatch: '*.spec.ts',
diff --git a/frontend/testing/playwright/tests/dashboard/dashboard-navigation.spec.ts b/frontend/testing/playwright/tests/dashboard/dashboard-navigation.spec.ts
new file mode 100644
index 00000000000..95f5bbc3362
--- /dev/null
+++ b/frontend/testing/playwright/tests/dashboard/dashboard-navigation.spec.ts
@@ -0,0 +1,48 @@
+import { expect } from '@playwright/test';
+import { test } from '../../extenders/testExtend';
+import { DesignerApi } from '../../helpers/DesignerApi';
+import type { StorageState } from '../../types/StorageState';
+import { Gitea } from '../../helpers/Gitea';
+import { OrgLibraryPage } from '../../pages/OrgLibraryPage';
+import { DashboardPage } from '../../pages/DashboardPage';
+import { DashboardHeader } from '../../components/DashboardHeader';
+
+const TEST_ORG: string = 'ttd';
+const ORG_LIBRARY_FEATURE_FLAG: string = '?featureFlags=orgLibrary&persistFeatureFlag=true';
+
+test.describe.configure({ mode: 'serial' });
+
+test.beforeAll(async ({ testAppName, request, storageState }) => {
+ const designerApi = new DesignerApi({ app: testAppName, org: TEST_ORG });
+ const response = await designerApi.createApp(request, storageState as StorageState);
+ expect(response.ok()).toBeTruthy();
+});
+
+test.afterAll(async ({ request, testAppName }) => {
+ const gitea = new Gitea();
+ const response = await request.delete(
+ gitea.getDeleteAppEndpoint({ app: testAppName, org: TEST_ORG }),
+ );
+ expect(response.ok()).toBeTruthy();
+});
+
+test('that it is possible to navigate from dashboard page to library page and back again', async ({
+ page,
+ testAppName,
+}) => {
+ const dashboardPage = new DashboardPage(page, { app: testAppName, org: TEST_ORG });
+ const dashboardHeader: DashboardHeader = new DashboardHeader(page, {
+ app: testAppName,
+ org: TEST_ORG,
+ });
+ const orgLibraryPage = new OrgLibraryPage(page, { app: testAppName, org: TEST_ORG });
+
+ await dashboardPage.loadDashboardPageAsOrg(ORG_LIBRARY_FEATURE_FLAG);
+ await dashboardPage.verifyDashboardPageAsOrg(ORG_LIBRARY_FEATURE_FLAG);
+ await dashboardHeader.clickOnNavigateToPageInTopMenuHeader('library');
+ await orgLibraryPage.verifyOrgLibraryPage();
+ await orgLibraryPage.waitForPageHeaderToBeVisible();
+
+ await dashboardHeader.clickOnNavigateToPageInTopMenuHeader('dashboard');
+ await dashboardPage.verifyDashboardPageAsOrg();
+});
diff --git a/frontend/testing/playwright/tests/org-library/codelists.spec.ts b/frontend/testing/playwright/tests/org-library/codelists.spec.ts
new file mode 100644
index 00000000000..5cc1403baa7
--- /dev/null
+++ b/frontend/testing/playwright/tests/org-library/codelists.spec.ts
@@ -0,0 +1,190 @@
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { test } from '../../extenders/testExtend';
+import { DesignerApi } from '../../helpers/DesignerApi';
+import type { StorageState } from '../../types/StorageState';
+import { Gitea } from '../../helpers/Gitea';
+import { OrgLibraryPage } from '../../pages/OrgLibraryPage';
+
+const TEST_ORG: string = 'ttd';
+const CODELIST_TITLE_MANUALLY: string = 'Test_codelist';
+const CODELIST_TITLE_UPLOADED: string = 'testCodelist';
+
+const EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST: number = 1;
+const EXPECTED_NUMBER_OF_ROWS_IN_MANUALLY_CREATED_CODELIST_AFTER_ADDING_ROW: number =
+ EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST + 1;
+const EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST: number = 3;
+const EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST_AFTER_DELETE: number =
+ EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST - 1;
+
+test.describe.configure({ mode: 'serial' });
+
+test.beforeAll(async ({ testAppName, request, storageState }) => {
+ const designerApi = new DesignerApi({ app: testAppName, org: TEST_ORG });
+ const response = await designerApi.createApp(request, storageState as StorageState);
+ expect(response.ok()).toBeTruthy();
+});
+
+test.afterAll(async ({ request, testAppName }) => {
+ const gitea = new Gitea();
+ const response = await request.delete(
+ gitea.getDeleteAppEndpoint({ app: testAppName, org: TEST_ORG }),
+ );
+ expect(response.ok()).toBeTruthy();
+});
+
+const setupAndVerifyCodeListPage = async (
+ page: Page,
+ testAppName: string,
+): Promise => {
+ const orgLibraryPage = new OrgLibraryPage(page, { app: testAppName, org: TEST_ORG });
+ await orgLibraryPage.loadOrgLibraryPage();
+ await orgLibraryPage.verifyOrgLibraryPage();
+ await orgLibraryPage.waitForPageHeaderToBeVisible();
+ await orgLibraryPage.clickOnNavigateToCodeListPage();
+ await orgLibraryPage.codeLists.waitForCodeListPageToLoad();
+
+ return orgLibraryPage;
+};
+
+test('that it is possible to create a new codelist', async ({ page, testAppName }) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyCodeListPage(page, testAppName);
+
+ await orgLibraryPage.codeLists.clickOnCreateNewCodelistButton();
+ await orgLibraryPage.codeLists.verifyNewCodelistModalIsOpen();
+ await orgLibraryPage.codeLists.writeCodelistTitle(CODELIST_TITLE_MANUALLY);
+ await orgLibraryPage.codeLists.clickOnAddAlternativeButton();
+
+ await orgLibraryPage.codeLists.verifyNewItemValueFieldIsVisible(
+ EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST,
+ );
+ const firstRowValue: string = 'First value';
+ await orgLibraryPage.codeLists.writeCodelistValue(
+ EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST,
+ firstRowValue,
+ );
+ const firstRowLabel: string = 'First label';
+ await orgLibraryPage.codeLists.writeCodelistLabel(
+ EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST,
+ firstRowLabel,
+ );
+
+ await orgLibraryPage.codeLists.clickOnSaveCodelistButton();
+ await orgLibraryPage.codeLists.verifyThatCodeListIsVisible(CODELIST_TITLE_MANUALLY);
+});
+
+test('that it is possible to add a new row to an existing codelist and modify the fields in the row', async ({
+ page,
+ testAppName,
+}) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyCodeListPage(page, testAppName);
+
+ await orgLibraryPage.codeLists.verifyThatCodeListIsVisible(CODELIST_TITLE_MANUALLY);
+ await orgLibraryPage.codeLists.clickOnCodeListAccordion(CODELIST_TITLE_MANUALLY);
+
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ EXPECTED_NUMBER_OF_ITEMS_IN_MANUALLY_CREATED_CODELIST,
+ CODELIST_TITLE_MANUALLY,
+ );
+
+ await orgLibraryPage.codeLists.clickOnAddItemButton();
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ EXPECTED_NUMBER_OF_ROWS_IN_MANUALLY_CREATED_CODELIST_AFTER_ADDING_ROW,
+ CODELIST_TITLE_MANUALLY,
+ );
+
+ const lastlyAddedItemNumber: number =
+ EXPECTED_NUMBER_OF_ROWS_IN_MANUALLY_CREATED_CODELIST_AFTER_ADDING_ROW;
+ await orgLibraryPage.codeLists.verifyEmptyValueTextfield(lastlyAddedItemNumber);
+ await orgLibraryPage.codeLists.verifyEmptyLabelTextfield(lastlyAddedItemNumber);
+
+ const value: string = 'value';
+ await orgLibraryPage.codeLists.writeCodelistValue(lastlyAddedItemNumber, value);
+ await orgLibraryPage.codeLists.verifyValueTextfield(lastlyAddedItemNumber, value);
+
+ const label: string = 'label';
+ await orgLibraryPage.codeLists.writeCodelistLabel(lastlyAddedItemNumber, label);
+ await orgLibraryPage.codeLists.verifyLabelTextfield(lastlyAddedItemNumber, label);
+});
+
+test('that it is possible to upload a new codelist', async ({ page, testAppName }) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyCodeListPage(page, testAppName);
+
+ const codelistFileName: string = `${CODELIST_TITLE_UPLOADED}.json`;
+ await orgLibraryPage.codeLists.clickOnUploadButtonAndSelectFileToUpload(codelistFileName);
+ await orgLibraryPage.codeLists.verifyThatCodeListIsVisible(CODELIST_TITLE_UPLOADED);
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST,
+ CODELIST_TITLE_UPLOADED,
+ );
+});
+
+test('that it is possible to delete a row in an uploaded codelist', async ({
+ page,
+ testAppName,
+}) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyCodeListPage(page, testAppName);
+
+ await orgLibraryPage.codeLists.clickOnCodeListAccordion(CODELIST_TITLE_UPLOADED);
+ await orgLibraryPage.codeLists.verifyThatCodeListIsVisible(CODELIST_TITLE_UPLOADED);
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST,
+ CODELIST_TITLE_UPLOADED,
+ );
+
+ const itemNumberOne: number = 1;
+ const firstItemValue: string = 'test1';
+ const secondItemValue: string = 'test2';
+ await orgLibraryPage.codeLists.verifyValueTextfield(itemNumberOne, firstItemValue);
+ await orgLibraryPage.codeLists.clickOnDeleteItemButton(itemNumberOne);
+
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST_AFTER_DELETE,
+ CODELIST_TITLE_UPLOADED,
+ );
+ await orgLibraryPage.codeLists.verifyValueTextfield(itemNumberOne, secondItemValue);
+});
+
+test('that it is possible to search for and delete the new codelists', async ({
+ page,
+ testAppName,
+}) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyCodeListPage(page, testAppName);
+
+ await searchForAndOpenCodeList(orgLibraryPage, CODELIST_TITLE_MANUALLY);
+ await deleteAndVerifyDeletionOfCodeList(
+ orgLibraryPage,
+ CODELIST_TITLE_MANUALLY,
+ EXPECTED_NUMBER_OF_ROWS_IN_MANUALLY_CREATED_CODELIST_AFTER_ADDING_ROW,
+ );
+
+ await searchForAndOpenCodeList(orgLibraryPage, CODELIST_TITLE_UPLOADED);
+ await deleteAndVerifyDeletionOfCodeList(
+ orgLibraryPage,
+ CODELIST_TITLE_UPLOADED,
+ EXPECTED_NUMBER_OF_ROWS_IN_UPLOADED_CODELIST_AFTER_DELETE,
+ );
+});
+
+const searchForAndOpenCodeList = async (
+ orgLibraryPage: OrgLibraryPage,
+ codelistTitle: string,
+): Promise => {
+ await orgLibraryPage.codeLists.typeInSearchBox(codelistTitle);
+ await orgLibraryPage.codeLists.verifyThatCodeListIsVisible(codelistTitle);
+ await orgLibraryPage.codeLists.clickOnCodeListAccordion(codelistTitle);
+};
+
+const deleteAndVerifyDeletionOfCodeList = async (
+ orgLibraryPage: OrgLibraryPage,
+ codelistTitle: string,
+ expectedNumberOfRowsInCodeList: number,
+): Promise => {
+ await orgLibraryPage.codeLists.verifyNumberOfItemsInTheCodelist(
+ expectedNumberOfRowsInCodeList,
+ codelistTitle,
+ );
+ await orgLibraryPage.codeLists.listenToAndWaitForConfirmDeleteCodeList(codelistTitle);
+ await orgLibraryPage.codeLists.clickOnDeleteCodelistButton();
+ await orgLibraryPage.codeLists.verifyThatCodeListIsNotVisible(codelistTitle);
+};
diff --git a/frontend/testing/playwright/tests/org-library/org-library.spec.ts b/frontend/testing/playwright/tests/org-library/org-library.spec.ts
new file mode 100644
index 00000000000..381290beb29
--- /dev/null
+++ b/frontend/testing/playwright/tests/org-library/org-library.spec.ts
@@ -0,0 +1,46 @@
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { test } from '../../extenders/testExtend';
+import { DesignerApi } from '../../helpers/DesignerApi';
+import type { StorageState } from '../../types/StorageState';
+import { Gitea } from '../../helpers/Gitea';
+import { OrgLibraryPage } from '../../pages/OrgLibraryPage';
+
+const TEST_ORG: string = 'ttd';
+
+test.describe.configure({ mode: 'serial' });
+
+test.beforeAll(async ({ testAppName, request, storageState }) => {
+ const designerApi = new DesignerApi({ app: testAppName, org: TEST_ORG });
+ const response = await designerApi.createApp(request, storageState as StorageState);
+ expect(response.ok()).toBeTruthy();
+});
+
+test.afterAll(async ({ request, testAppName }) => {
+ const gitea = new Gitea();
+ const response = await request.delete(
+ gitea.getDeleteAppEndpoint({ app: testAppName, org: TEST_ORG }),
+ );
+ expect(response.ok()).toBeTruthy();
+});
+
+const setupAndVerifyOrgLibraryPage = async (
+ page: Page,
+ testAppName: string,
+): Promise => {
+ const orgLibraryPage = new OrgLibraryPage(page, { app: testAppName, org: TEST_ORG });
+ await orgLibraryPage.loadOrgLibraryPage();
+ await orgLibraryPage.verifyOrgLibraryPage();
+ await orgLibraryPage.waitForPageHeaderToBeVisible();
+ return orgLibraryPage;
+};
+
+test('that it is possible to navigate to code list page and that the page is empty', async ({
+ page,
+ testAppName,
+}) => {
+ const orgLibraryPage: OrgLibraryPage = await setupAndVerifyOrgLibraryPage(page, testAppName);
+
+ await orgLibraryPage.clickOnNavigateToCodeListPage();
+ await orgLibraryPage.codeLists.waitForCodeListPageToLoad();
+});