diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 7d337cfac78..6fd1dd1471d 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -849,6 +849,8 @@ "resourceadm.about_resource_error_usage_string_title": "tittel", "resourceadm.about_resource_homepage_label": "Hjemmeside", "resourceadm.about_resource_homepage_text": "Lenke til informasjon om hvor sluttbruker kan finne tjenesten og informasjon om den.", + "resourceadm.about_resource_identifier_description": "Dette er en unik ID for ressursen både i URler og i APIer.", + "resourceadm.about_resource_identifier_label": "Ressurs-id", "resourceadm.about_resource_keywords_label": "Nøkkelord", "resourceadm.about_resource_keywords_text": "Ord som er enkle å søke på. Separer hvert ord med komma \",\".", "resourceadm.about_resource_langauge_error_missing_1": "Du mangler oversettelse for {{usageString}} på {{lang}}.", @@ -877,6 +879,7 @@ "resourceadm.about_resource_resource_title_label": "Navn på tjenesten (Bokmål)", "resourceadm.about_resource_resource_title_text": "Navnet vil synes for brukere, og må være gjenkjennelig og beskrivende for hva tjenesten handler om. Pass på å skille tjenester som har like eller nesten like navn, slik at det blir lett for brukere å forstå.", "resourceadm.about_resource_resource_type": "Ressurstype", + "resourceadm.about_resource_resource_type_brokerservice": "Formidlingstjeneste", "resourceadm.about_resource_resource_type_error": "Du mangler å legge til ressurstype.", "resourceadm.about_resource_resource_type_generic_access_resource": "Generisk tilgangsressurs", "resourceadm.about_resource_resource_type_label": "Velg en ressurstype fra listen under.", @@ -938,6 +941,7 @@ "resourceadm.dashboard_table_header_last_changed": "Sist endret", "resourceadm.dashboard_table_header_name": "Navn", "resourceadm.dashboard_table_header_policy_rules": "Tilgangsregler", + "resourceadm.dashboard_table_header_resourceid": "Ressurs-id", "resourceadm.dashboard_table_row_edit": "Rediger ressurs", "resourceadm.dashboard_table_row_has_policy": "Har tilgangsregler", "resourceadm.dashboard_table_row_missing_policy": "Mangler tilgangsregler", @@ -981,6 +985,7 @@ "resourceadm.left_nav_bar_organization_access": "Organisasjonstilganger", "resourceadm.left_nav_bar_policy": "Tilgangsregler", "resourceadm.listadmin_add_to_list": "Legg til i liste", + "resourceadm.listadmin_add_to_list_org": "Legg til {{org}} i liste", "resourceadm.listadmin_back": "Tilbake til dashboard", "resourceadm.listadmin_confirm_create_list": "Opprett tilgangsliste", "resourceadm.listadmin_create_list": "Opprett ny tilgangsliste", @@ -1014,6 +1019,7 @@ "resourceadm.listadmin_parties": "Enheter", "resourceadm.listadmin_party": "Enhet", "resourceadm.listadmin_remove_from_list": "Fjern fra liste", + "resourceadm.listadmin_remove_from_list_org": "Fjern {{org}} fra liste", "resourceadm.listadmin_resource_header": "Tilgangslister for {{resourceTitle}} - {{env}}", "resourceadm.listadmin_resource_list_checkbox_header": "Alle enheter og underenheter i valgte liste(r) kan bruke ressursen", "resourceadm.listadmin_search": "Søk for å legge til ny enhet eller underenhet", diff --git a/frontend/packages/shared/src/types/Altinn2LinkService.ts b/frontend/packages/shared/src/types/Altinn2LinkService.ts index dec93277f47..e0247e66581 100644 --- a/frontend/packages/shared/src/types/Altinn2LinkService.ts +++ b/frontend/packages/shared/src/types/Altinn2LinkService.ts @@ -1,4 +1,5 @@ export interface Altinn2LinkService { + serviceOwnerCode: string; serviceName: string; externalServiceCode: string; externalServiceEditionCode: string; diff --git a/frontend/packages/shared/src/types/ResourceAdm.ts b/frontend/packages/shared/src/types/ResourceAdm.ts index c05a9e269ae..5baf13a46cc 100644 --- a/frontend/packages/shared/src/types/ResourceAdm.ts +++ b/frontend/packages/shared/src/types/ResourceAdm.ts @@ -33,7 +33,11 @@ export interface ResourceContactPoint { contactPage: string; } -export type ResourceTypeOption = 'GenericAccessResource' | 'Systemresource' | 'MaskinportenSchema'; +export type ResourceTypeOption = + | 'GenericAccessResource' + | 'Systemresource' + | 'MaskinportenSchema' + | 'BrokerService'; export type ResourceStatusOption = 'Completed' | 'Deprecated' | 'UnderDevelopment' | 'Withdrawn'; diff --git a/frontend/resourceadm/components/AccessListDetails/AccessListDetail.tsx b/frontend/resourceadm/components/AccessListDetails/AccessListDetail.tsx index c4acb02981b..c4a11c5e7f5 100644 --- a/frontend/resourceadm/components/AccessListDetails/AccessListDetail.tsx +++ b/frontend/resourceadm/components/AccessListDetails/AccessListDetail.tsx @@ -84,7 +84,7 @@ export const AccessListDetail = ({ label={t('resourceadm.listadmin_list_id')} description={t('resourceadm.listadmin_list_id_description')} > - + - {t('resourceadm.listadmin_remove_from_list')} - - - } onButtonClick={(item: AccessListMember) => handleRemoveMember(item.orgNr)} /> {listItems.length === 0 && ( @@ -115,15 +109,8 @@ export const AccessListMembers = ({ - !!listItems.find((listItem) => disableItem.orgNr === listItem.orgNr) - } - buttonNode={ - <> - {t('resourceadm.listadmin_add_to_list')} - - - } + disabledItems={listItems} + isAdd onButtonClick={handleAddMember} /> {(isLoadingParties || isLoadingSubParties) && ( diff --git a/frontend/resourceadm/components/AccessListMembers/AccessListMembersTable.tsx b/frontend/resourceadm/components/AccessListMembers/AccessListMembersTable.tsx index bf3d4826fff..8e4025878c3 100644 --- a/frontend/resourceadm/components/AccessListMembers/AccessListMembersTable.tsx +++ b/frontend/resourceadm/components/AccessListMembers/AccessListMembersTable.tsx @@ -4,24 +4,57 @@ import { Table } from '@digdir/design-system-react'; import type { AccessListMember } from 'app-shared/types/ResourceAdm'; import { StudioButton } from '@studio/components'; import classes from './AccessListMembers.module.css'; +import { PlusCircleIcon, MinusCircleIcon } from '@studio/icons'; +import { stringNumberToAriaLabel } from '../../utils/stringUtils'; interface AccessListMembersTableProps { listItems: AccessListMember[]; - buttonNode: React.JSX.Element; + isAdd?: boolean; isHeaderHidden?: boolean; - disableButtonFn?: (member: AccessListMember) => boolean; + disabledItems?: AccessListMember[]; onButtonClick: (member: AccessListMember) => void; } export const AccessListMembersTable = ({ listItems, - buttonNode, + isAdd, isHeaderHidden, - disableButtonFn, + disabledItems, onButtonClick, }: AccessListMembersTableProps): React.JSX.Element => { const { t } = useTranslation(); + const renderActionButton = (item: AccessListMember): React.JSX.Element => { + let buttonAriaLabel: string; + let buttonIcon: React.JSX.Element; + let buttonText: string; + if (isAdd) { + buttonAriaLabel = t('resourceadm.listadmin_add_to_list_org', { org: item.orgName }); + buttonIcon = ; + buttonText = t('resourceadm.listadmin_add_to_list'); + } else { + buttonAriaLabel = t('resourceadm.listadmin_remove_from_list_org', { + org: item.orgName, + }); + buttonIcon = ; + buttonText = t('resourceadm.listadmin_remove_from_list'); + } + return ( + onButtonClick(item)} + disabled={ + disabledItems && disabledItems.some((existingItem) => existingItem.orgNr === item.orgNr) + } + variant='tertiary' + size='small' + > + {buttonText} + {buttonIcon} + + ); + }; + return ( @@ -42,23 +75,14 @@ export const AccessListMembersTable = ({ {listItems.map((item) => { return ( - {item.orgNr} + {item.orgNr} {item.orgName || t('resourceadm.listadmin_empty_name')} {item.isSubParty ? t('resourceadm.listadmin_sub_party') : t('resourceadm.listadmin_party')} - - onButtonClick(item)} - disabled={disableButtonFn ? disableButtonFn(item) : false} - variant='tertiary' - size='small' - > - {buttonNode} - - + {renderActionButton(item)} ); })} diff --git a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx index 2e917b3a6c5..16ea5aa2b67 100644 --- a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx +++ b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx @@ -13,12 +13,13 @@ import type { Altinn2LinkService } from 'app-shared/types/Altinn2LinkService'; import { ServerCodes } from 'app-shared/enums/ServerCodes'; const mockAltinn2LinkService: Altinn2LinkService = { + serviceOwnerCode: 'ttd', externalServiceCode: 'code1', externalServiceEditionCode: 'edition1', serviceName: 'TestService', }; const mockAltinn2LinkServices: Altinn2LinkService[] = [mockAltinn2LinkService]; -const mockOption: string = `${mockAltinn2LinkService.externalServiceCode}-${mockAltinn2LinkService.externalServiceEditionCode}-${mockAltinn2LinkService.serviceName}`; +const mockOption: string = `${mockAltinn2LinkService.serviceOwnerCode}: ${mockAltinn2LinkService.externalServiceCode}-${mockAltinn2LinkService.externalServiceEditionCode}-${mockAltinn2LinkService.serviceName}`; const mockOnClose = jest.fn(); const getAltinn2LinkServices = jest diff --git a/frontend/resourceadm/components/ImportResourceModal/ServiceContent/ServiceContent.test.tsx b/frontend/resourceadm/components/ImportResourceModal/ServiceContent/ServiceContent.test.tsx index 12937896521..5620b1c6f60 100644 --- a/frontend/resourceadm/components/ImportResourceModal/ServiceContent/ServiceContent.test.tsx +++ b/frontend/resourceadm/components/ImportResourceModal/ServiceContent/ServiceContent.test.tsx @@ -15,11 +15,13 @@ const mockSelectedContext: string = 'selectedContext'; const mockEnv: string = 'env1'; const mockAltinn2LinkService: Altinn2LinkService = { + serviceOwnerCode: 'ttd', externalServiceCode: 'code1', externalServiceEditionCode: 'edition1', serviceName: 'TestService', }; const mockAltinn2HyphenLinkService: Altinn2LinkService = { + serviceOwnerCode: 'ttd', externalServiceCode: 'code2', externalServiceEditionCode: 'edition2', serviceName: 'test-med---hyphens', @@ -28,8 +30,8 @@ const mockAltinn2LinkServices: Altinn2LinkService[] = [ mockAltinn2LinkService, mockAltinn2HyphenLinkService, ]; -const mockOption: string = `${mockAltinn2LinkService.externalServiceCode}-${mockAltinn2LinkService.externalServiceEditionCode}-${mockAltinn2LinkService.serviceName}`; -const mockHyphenOption: string = `${mockAltinn2HyphenLinkService.externalServiceCode}-${mockAltinn2HyphenLinkService.externalServiceEditionCode}-${mockAltinn2HyphenLinkService.serviceName}`; +const mockOption: string = `${mockAltinn2LinkService.serviceOwnerCode}: ${mockAltinn2LinkService.externalServiceCode}-${mockAltinn2LinkService.externalServiceEditionCode}-${mockAltinn2LinkService.serviceName}`; +const mockHyphenOption: string = `${mockAltinn2HyphenLinkService.serviceOwnerCode}: ${mockAltinn2HyphenLinkService.externalServiceCode}-${mockAltinn2HyphenLinkService.externalServiceEditionCode}-${mockAltinn2HyphenLinkService.serviceName}`; const mockOnSelectService = jest.fn(); diff --git a/frontend/resourceadm/components/ResourcePageInputs/ResourceTextField.tsx b/frontend/resourceadm/components/ResourcePageInputs/ResourceTextField.tsx index 719c604d286..01f02ebaab7 100644 --- a/frontend/resourceadm/components/ResourcePageInputs/ResourceTextField.tsx +++ b/frontend/resourceadm/components/ResourcePageInputs/ResourceTextField.tsx @@ -44,6 +44,10 @@ type ResourceTextFieldProps = { * Whether this field is required or not */ required?: boolean; + /** + * Whether this field is read only or not + */ + readOnly?: boolean; }; /** @@ -59,6 +63,7 @@ type ResourceTextFieldProps = { * @property {boolean}[showErrorMessage] - Flag for if the error message should be shown * @property {string}[errorText] - The text to be shown * @property {boolean}[required] - Whether this field is required or not + * @property {boolean}[readOnly] - Whether this field is read only or not * * @returns {React.JSX.Element} - The rendered component */ @@ -74,6 +79,7 @@ export const ResourceTextField = forwardRef { @@ -94,6 +100,7 @@ export const ResourceTextField = forwardRef onBlur(val)} required={required} + readOnly={readOnly} /> {showErrorMessage && } diff --git a/frontend/resourceadm/components/ResourceTable/ResourceTable.tsx b/frontend/resourceadm/components/ResourceTable/ResourceTable.tsx index 27e8d4b95ca..033dd865c0e 100644 --- a/frontend/resourceadm/components/ResourceTable/ResourceTable.tsx +++ b/frontend/resourceadm/components/ResourceTable/ResourceTable.tsx @@ -71,6 +71,11 @@ export const ResourceTable = ({ headerName: t('resourceadm.dashboard_table_header_name'), width: 200, }, + { + field: 'identifier', + headerName: t('resourceadm.dashboard_table_header_resourceid'), + width: 200, + }, { field: 'createdBy', headerName: t('resourceadm.dashboard_table_header_createdby'), diff --git a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx index a290706b53f..4f5991274dd 100644 --- a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx +++ b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx @@ -86,6 +86,17 @@ describe('AboutResourcePage', () => { id: mockId, }; + it('handles resource id field blur', async () => { + render(); + + const idInput = screen.getByLabelText(textMock('resourceadm.about_resource_identifier_label')); + + await act(() => idInput.focus()); + await act(() => idInput.blur()); + + expect(mockOnSaveResource).not.toHaveBeenCalled(); + }); + it('handles resource type change', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx index d2b981d914f..d9ea74c2ea4 100644 --- a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx +++ b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx @@ -102,6 +102,14 @@ export const AboutResourcePage = ({ {t('resourceadm.about_resource_title')} + setTranslationType('none')} + onBlur={() => {}} + /> { const { t } = useTranslation(); diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx index 363ae6ee18f..c8f4f852ce6 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx @@ -286,7 +286,7 @@ export const ResourcePage = (): React.JSX.Element => { onSaveVersion={(version: string) => handleSaveResource({ ...resourceData, - version, + version: version?.trim(), // empty version is not allowed }) } id='page-content-deploy' diff --git a/frontend/resourceadm/utils/mapperUtils/mapperUtils.test.ts b/frontend/resourceadm/utils/mapperUtils/mapperUtils.test.ts index 23e01921ee0..38eff0b40fc 100644 --- a/frontend/resourceadm/utils/mapperUtils/mapperUtils.test.ts +++ b/frontend/resourceadm/utils/mapperUtils/mapperUtils.test.ts @@ -5,23 +5,25 @@ describe('mapperUtils', () => { describe('mapAltinn2LinkServiceToSelectOption', () => { const mockLinkServices: Altinn2LinkService[] = [ { + serviceOwnerCode: 'ttd', externalServiceCode: 'code1', externalServiceEditionCode: 'edition1', serviceName: 'name1', }, { + serviceOwnerCode: 'acn', externalServiceCode: 'code2', externalServiceEditionCode: 'edition2', serviceName: 'name2', }, ]; - it('should map Altinn2LinkService to SelectOption correctly', () => { + it('should map and sort Altinn2LinkService to SelectOption correctly', () => { const result = mapAltinn2LinkServiceToSelectOption(mockLinkServices); expect(result).toHaveLength(mockLinkServices.length); - expect(result[0].value).toBe(JSON.stringify(mockLinkServices[0])); - expect(result[0].label).toBe('code1-edition1-name1'); + expect(result[0].value).toBe(JSON.stringify(mockLinkServices[1])); + expect(result[0].label).toBe('acn: code2-edition2-name2'); }); }); }); diff --git a/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts b/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts index 486a27a1e41..421b83a3e5c 100644 --- a/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts +++ b/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts @@ -23,9 +23,15 @@ export const sortResourceListByDate = (resourceList: ResourceListItem[]): Resour * @returns an object that looks like this: { value: string, label: string } */ export const mapAltinn2LinkServiceToSelectOption = (linkServices: Altinn2LinkService[]) => { - return linkServices.map((ls: Altinn2LinkService) => ({ + const sortedServices = [...linkServices].sort((a, b) => { + const serviceOwnerValue = a.serviceOwnerCode.localeCompare(b.serviceOwnerCode); + return serviceOwnerValue === 0 + ? a.externalServiceCode.localeCompare(b.externalServiceCode) + : serviceOwnerValue; + }); + return sortedServices.map((ls: Altinn2LinkService) => ({ value: JSON.stringify(ls), - label: `${ls.externalServiceCode}-${ls.externalServiceEditionCode}-${ls.serviceName}`, + label: `${ls.serviceOwnerCode}: ${ls.externalServiceCode}-${ls.externalServiceEditionCode}-${ls.serviceName}`, })); }; diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts index 5c0da2b5f55..fa6ae0bd01c 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts @@ -19,6 +19,7 @@ export const resourceTypeMap: Record = { GenericAccessResource: 'resourceadm.about_resource_resource_type_generic_access_resource', Systemresource: 'resourceadm.about_resource_resource_type_system_resource', MaskinportenSchema: 'resourceadm.about_resource_resource_type_maskinporten', + BrokerService: 'resourceadm.about_resource_resource_type_brokerservice', }; /** diff --git a/frontend/resourceadm/utils/stringUtils/index.ts b/frontend/resourceadm/utils/stringUtils/index.ts index ed5b957f98a..75bf35bfce0 100644 --- a/frontend/resourceadm/utils/stringUtils/index.ts +++ b/frontend/resourceadm/utils/stringUtils/index.ts @@ -1 +1 @@ -export { formatIdString, isAppPrefix, isSePrefix } from './stringUtils'; +export { formatIdString, isAppPrefix, isSePrefix, stringNumberToAriaLabel } from './stringUtils'; diff --git a/frontend/resourceadm/utils/stringUtils/stringUtils.ts b/frontend/resourceadm/utils/stringUtils/stringUtils.ts index b52664fc057..58758509a9c 100644 --- a/frontend/resourceadm/utils/stringUtils/stringUtils.ts +++ b/frontend/resourceadm/utils/stringUtils/stringUtils.ts @@ -15,3 +15,14 @@ export const isAppPrefix = (s: string): boolean => { export const isSePrefix = (s: string): boolean => { return s.substring(0, 3) === 'se_'; }; + +/** + * Numbers with a specific meaning (postal numbers, phone numbers etc) should not be + * read by screen readers as millons-thousands etc, but rather as groups of numbers. + * + * @param s the string to format + * @returns the string formatted + */ +export const stringNumberToAriaLabel = (s: string): string => { + return s.split('').join(' '); +};