mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'master' into w2p-70237_entities-orgunit-submission-fix
This commit is contained in:
@@ -145,6 +145,14 @@ module.exports = {
|
||||
code: 'pt',
|
||||
label: 'Português',
|
||||
active: true,
|
||||
}, {
|
||||
code: 'fr',
|
||||
label: 'Français',
|
||||
active: true,
|
||||
}, {
|
||||
code: 'lv',
|
||||
label: 'Latviešu',
|
||||
active: true,
|
||||
}],
|
||||
// Browse-By Pages
|
||||
browseBy: {
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
|
||||
"admin.registries.bitstream-formats.edit.extensions.label": "File extensions",
|
||||
|
||||
"admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extenstion without the dot",
|
||||
"admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extension without the dot",
|
||||
|
||||
"admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.",
|
||||
|
||||
@@ -176,12 +176,12 @@
|
||||
|
||||
"admin.access-control.epeople.search.head": "Search",
|
||||
|
||||
"admin.access-control.epeople.search.scope.name": "Name",
|
||||
|
||||
"admin.access-control.epeople.search.scope.email": "E-mail (exact)",
|
||||
"admin.access-control.epeople.button.see-all": "Browse All",
|
||||
|
||||
"admin.access-control.epeople.search.scope.metadata": "Metadata",
|
||||
|
||||
"admin.access-control.epeople.search.scope.email": "E-mail (exact)",
|
||||
|
||||
"admin.access-control.epeople.search.button": "Search",
|
||||
|
||||
"admin.access-control.epeople.button.add": "Add EPerson",
|
||||
@@ -190,13 +190,13 @@
|
||||
|
||||
"admin.access-control.epeople.table.name": "Name",
|
||||
|
||||
"admin.access-control.epeople.table.email": "E-mail",
|
||||
"admin.access-control.epeople.table.email": "E-mail (exact)",
|
||||
|
||||
"admin.access-control.epeople.table.edit": "Edit",
|
||||
|
||||
"item.access-control.epeople.table.edit.buttons.edit": "Edit",
|
||||
"admin.access-control.epeople.table.edit.buttons.edit": "Edit \"{{name}}\"",
|
||||
|
||||
"item.access-control.epeople.table.edit.buttons.remove": "Remove",
|
||||
"admin.access-control.epeople.table.edit.buttons.remove": "Delete \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.no-items": "No EPeople to show.",
|
||||
|
||||
@@ -228,12 +228,149 @@
|
||||
|
||||
"admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Member of these groups:",
|
||||
|
||||
"admin.access-control.epeople.form.table.id": "ID",
|
||||
|
||||
"admin.access-control.epeople.form.table.name": "Name",
|
||||
|
||||
"admin.access-control.epeople.form.memberOfNoGroups": "This EPerson is not a member of any groups",
|
||||
|
||||
"admin.access-control.epeople.form.goToGroups": "Add to groups",
|
||||
|
||||
"admin.access-control.epeople.notification.deleted.failure": "Failed to delete EPerson: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.notification.deleted.success": "Successfully deleted EPerson: \"{{name}}\"",
|
||||
|
||||
|
||||
|
||||
"admin.access-control.groups.title": "DSpace Angular :: Groups",
|
||||
|
||||
"admin.access-control.groups.head": "Groups",
|
||||
|
||||
"admin.access-control.groups.button.add": "Add group",
|
||||
|
||||
"admin.access-control.groups.search.head": "Search groups",
|
||||
|
||||
"admin.access-control.groups.button.see-all": "Browse all",
|
||||
|
||||
"admin.access-control.groups.search.button": "Search",
|
||||
|
||||
"admin.access-control.groups.table.id": "ID",
|
||||
|
||||
"admin.access-control.groups.table.name": "Name",
|
||||
|
||||
"admin.access-control.groups.table.members": "Members",
|
||||
|
||||
"admin.access-control.groups.table.comcol": "Community / Collection",
|
||||
|
||||
"admin.access-control.groups.table.edit": "Edit",
|
||||
|
||||
"admin.access-control.groups.table.edit.buttons.edit": "Edit \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.table.edit.buttons.remove": "Delete \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.no-items": "No groups found with this in their name or this as UUID",
|
||||
|
||||
"admin.access-control.groups.notification.deleted.success": "Successfully deleted group \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.notification.deleted.failure": "Failed to delete group \"{{name}}\"",
|
||||
|
||||
|
||||
"admin.access-control.groups.form.head.create": "Create group",
|
||||
|
||||
"admin.access-control.groups.form.head.edit": "Edit group",
|
||||
|
||||
"admin.access-control.groups.form.groupName": "Group name",
|
||||
|
||||
"admin.access-control.groups.form.groupDescription": "Description",
|
||||
|
||||
"admin.access-control.groups.form.notification.created.success": "Successfully created Group \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.notification.created.failure": "Failed to create Group \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.notification.created.failure.groupNameInUse": "Failed to create Group with name: \"{{name}}\", make sure the name is not already in use.",
|
||||
|
||||
"admin.access-control.groups.form.members-list.head": "EPeople",
|
||||
|
||||
"admin.access-control.groups.form.members-list.search.head": "Add EPeople",
|
||||
|
||||
"admin.access-control.groups.form.members-list.button.see-all": "Browse All",
|
||||
|
||||
"admin.access-control.groups.form.members-list.headMembers": "Current Members",
|
||||
|
||||
"admin.access-control.groups.form.members-list.search.scope.metadata": "Metadata",
|
||||
|
||||
"admin.access-control.groups.form.members-list.search.scope.email": "E-mail (exact)",
|
||||
|
||||
"admin.access-control.groups.form.members-list.search.button": "Search",
|
||||
|
||||
"admin.access-control.groups.form.members-list.table.id": "ID",
|
||||
|
||||
"admin.access-control.groups.form.members-list.table.name": "Name",
|
||||
|
||||
"admin.access-control.groups.form.members-list.table.edit": "Remove / Add",
|
||||
|
||||
"admin.access-control.groups.form.members-list.table.edit.buttons.remove": "Remove member with name \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.notification.success.addMember": "Successfully added member: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.notification.failure.addMember": "Failed to add member: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.notification.success.deleteMember": "Successfully deleted member: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.notification.failure.deleteMember": "Failed to delete member: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.table.edit.buttons.add": "Add member with name \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.members-list.notification.failure.noActiveGroup": "No current active group, submit a name first.",
|
||||
|
||||
"admin.access-control.groups.form.members-list.no-members-yet": "No members in group yet, search and add.",
|
||||
|
||||
"admin.access-control.groups.form.members-list.no-items": "No EPeople found in that search",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.head": "Groups",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.search.head": "Add Subgroup",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.button.see-all": "Browse All",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.headSubgroups": "Current Subgroups",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.search.button": "Search",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.id": "ID",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.name": "Name",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.edit": "Remove / Add",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.edit.buttons.remove": "Remove subgroup with name \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.edit.buttons.add": "Add subgroup with name \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.table.edit.currentGroup": "Current group",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.success.addSubgroup": "Successfully added subgroup: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.failure.addSubgroup": "Failed to add subgroup: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.success.deleteSubgroup": "Successfully deleted subgroup: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.failure.deleteSubgroup": "Failed to delete subgroup: \"{{name}}\"",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.failure.noActiveGroup": "No current active group, submit a name first.",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.notification.failure.subgroupToAddIsActiveGroup": "This is the current group, can't be added.",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.no-items": "No groups found with this in their name or this as UUID",
|
||||
|
||||
"admin.access-control.groups.form.subgroups-list.no-subgroups-yet": "No subgroups in group yet.",
|
||||
|
||||
"admin.access-control.groups.form.return": "Return to groups",
|
||||
|
||||
|
||||
|
||||
"admin.search.breadcrumbs": "Administrative Search",
|
||||
|
||||
"admin.search.collection.edit": "Edit",
|
||||
@@ -268,6 +405,42 @@
|
||||
|
||||
|
||||
|
||||
"bitstream.edit.bitstream": "Bitstream: ",
|
||||
|
||||
"bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"<i>Main article</i>\" or \"<i>Experiment data readings</i>\".",
|
||||
|
||||
"bitstream.edit.form.description.label": "Description",
|
||||
|
||||
"bitstream.edit.form.embargo.hint": "The first day from which access is allowed. <b>This date cannot be modified on this form.</b> To set an embargo date for a bitstream, go to the <i>Item Status</i> tab, click <i>Authorizations...</i>, create or edit the bitstream's <i>READ</i> policy, and set the <i>Start Date</i> as desired.",
|
||||
|
||||
"bitstream.edit.form.embargo.label": "Embargo until specific date",
|
||||
|
||||
"bitstream.edit.form.fileName.hint": "Change the filename for the bitstream. Note that this will change the display bitstream URL, but old links will still resolve as long as the sequence ID does not change.",
|
||||
|
||||
"bitstream.edit.form.fileName.label": "Filename",
|
||||
|
||||
"bitstream.edit.form.newFormat.label": "Describe new format",
|
||||
|
||||
"bitstream.edit.form.newFormat.hint": "The application you used to create the file, and the version number (for example, \"<i>ACMESoft SuperApp version 1.5</i>\").",
|
||||
|
||||
"bitstream.edit.form.primaryBitstream.label": "Primary bitstream",
|
||||
|
||||
"bitstream.edit.form.selectedFormat.hint": "If the format is not in the above list, <b>select \"format not in list\" above</b> and describe it under \"Describe new format\".",
|
||||
|
||||
"bitstream.edit.form.selectedFormat.label": "Selected Format",
|
||||
|
||||
"bitstream.edit.form.selectedFormat.unknown": "Format not in list",
|
||||
|
||||
"bitstream.edit.notifications.error.format.title": "An error occurred saving the bitstream's format",
|
||||
|
||||
"bitstream.edit.notifications.saved.content": "Your changes to this bitstream were saved.",
|
||||
|
||||
"bitstream.edit.notifications.saved.title": "Bitstream saved",
|
||||
|
||||
"bitstream.edit.title": "Edit bitstream",
|
||||
|
||||
|
||||
|
||||
"browse.comcol.by.author": "By Author",
|
||||
|
||||
"browse.comcol.by.dateissued": "By Issue Date",
|
||||
@@ -590,6 +763,60 @@
|
||||
|
||||
|
||||
|
||||
"comcol-role.edit.no-group": "None",
|
||||
|
||||
"comcol-role.edit.create": "Create",
|
||||
|
||||
"comcol-role.edit.restrict": "Restrict",
|
||||
|
||||
"comcol-role.edit.delete": "Delete",
|
||||
|
||||
|
||||
"comcol-role.edit.community-admin.name": "Administrators",
|
||||
|
||||
"comcol-role.edit.collection-admin.name": "Administrators",
|
||||
|
||||
|
||||
"comcol-role.edit.community-admin.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).",
|
||||
|
||||
"comcol-role.edit.collection-admin.description": "Collection administrators decide who can submit items to the collection, edit item metadata (after submission), and add (map) existing items from other collections to this collection (subject to authorization for that collection).",
|
||||
|
||||
|
||||
"comcol-role.edit.submitters.name": "Submitters",
|
||||
|
||||
"comcol-role.edit.submitters.description": "The E-People and Groups that have permission to submit new items to this collection.",
|
||||
|
||||
|
||||
"comcol-role.edit.item_read.name": "Default item read access",
|
||||
|
||||
"comcol-role.edit.item_read.description": "E-People and Groups that can read new items submitted to this collection. Changes to this role are not retroactive. Existing items in the system will still be viewable by those who had read access at the time of their addition.",
|
||||
|
||||
"comcol-role.edit.item_read.anonymous-group": "Default read for incoming items is currently set to Anonymous.",
|
||||
|
||||
|
||||
"comcol-role.edit.bitstream_read.name": "Default bitstream read access",
|
||||
|
||||
"comcol-role.edit.bitstream_read.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).",
|
||||
|
||||
"comcol-role.edit.bitstream_read.anonymous-group": "Default read for incoming bitstreams is currently set to Anonymous.",
|
||||
|
||||
|
||||
"comcol-role.edit.editor.name": "Editors",
|
||||
|
||||
"comcol-role.edit.editor.description": "Editors are able to edit the metadata of incoming submissions, and then accept or reject them.",
|
||||
|
||||
|
||||
"comcol-role.edit.finaleditor.name": "Final editors",
|
||||
|
||||
"comcol-role.edit.finaleditor.description": "Final editors are able to edit the metadata of incoming submissions, but will not be able to reject them.",
|
||||
|
||||
|
||||
"comcol-role.edit.reviewer.name": "Reviewers",
|
||||
|
||||
"comcol-role.edit.reviewer.description": "Reviewers are able to accept or reject incoming submissions. However, they are not able to edit the submission's metadata.",
|
||||
|
||||
|
||||
|
||||
"community.form.abstract": "Short Description",
|
||||
|
||||
"community.form.description": "Introductory text (HTML)",
|
||||
@@ -638,6 +865,8 @@
|
||||
|
||||
|
||||
|
||||
"error.bitstream": "Error fetching bitstream",
|
||||
|
||||
"error.browse-by": "Error fetching items",
|
||||
|
||||
"error.collection": "Error fetching collection",
|
||||
@@ -745,6 +974,93 @@
|
||||
|
||||
|
||||
|
||||
|
||||
"item.bitstreams.upload.bundle": "Bundle",
|
||||
|
||||
"item.bitstreams.upload.bundle.placeholder": "Select a bundle",
|
||||
|
||||
"item.bitstreams.upload.bundle.new": "Create bundle",
|
||||
|
||||
"item.bitstreams.upload.bundles.empty": "This item doesn\'t contain any bundles to upload a bitstream to.",
|
||||
|
||||
"item.bitstreams.upload.cancel": "Cancel",
|
||||
|
||||
"item.bitstreams.upload.drop-message": "Drop a file to upload",
|
||||
|
||||
"item.bitstreams.upload.item": "Item: ",
|
||||
|
||||
"item.bitstreams.upload.notifications.bundle.created.content": "Successfully created new bundle.",
|
||||
|
||||
"item.bitstreams.upload.notifications.bundle.created.title": "Created bundle",
|
||||
|
||||
"item.bitstreams.upload.notifications.upload.failed": "Upload failed. Please verify the content before retrying.",
|
||||
|
||||
"item.bitstreams.upload.title": "Upload bitstream",
|
||||
|
||||
|
||||
|
||||
"item.edit.bitstreams.bundle.edit.buttons.upload": "Upload",
|
||||
|
||||
"item.edit.bitstreams.bundle.displaying": "Currently displaying {{ amount }} bitstreams of {{ total }}.",
|
||||
|
||||
"item.edit.bitstreams.bundle.load.all": "Load all ({{ total }})",
|
||||
|
||||
"item.edit.bitstreams.bundle.load.more": "Load more",
|
||||
|
||||
"item.edit.bitstreams.bundle.name": "BUNDLE: {{ name }}",
|
||||
|
||||
"item.edit.bitstreams.discard-button": "Discard",
|
||||
|
||||
"item.edit.bitstreams.edit.buttons.download": "Download",
|
||||
|
||||
"item.edit.bitstreams.edit.buttons.drag": "Drag",
|
||||
|
||||
"item.edit.bitstreams.edit.buttons.edit": "Edit",
|
||||
|
||||
"item.edit.bitstreams.edit.buttons.remove": "Remove",
|
||||
|
||||
"item.edit.bitstreams.edit.buttons.undo": "Undo changes",
|
||||
|
||||
"item.edit.bitstreams.empty": "This item doesn't contain any bitstreams. Click the upload button to create one.",
|
||||
|
||||
"item.edit.bitstreams.headers.actions": "Actions",
|
||||
|
||||
"item.edit.bitstreams.headers.bundle": "Bundle",
|
||||
|
||||
"item.edit.bitstreams.headers.description": "Description",
|
||||
|
||||
"item.edit.bitstreams.headers.format": "Format",
|
||||
|
||||
"item.edit.bitstreams.headers.name": "Name",
|
||||
|
||||
"item.edit.bitstreams.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button",
|
||||
|
||||
"item.edit.bitstreams.notifications.discarded.title": "Changes discarded",
|
||||
|
||||
"item.edit.bitstreams.notifications.move.failed.title": "Error moving bitstreams",
|
||||
|
||||
"item.edit.bitstreams.notifications.move.saved.content": "Your move changes to this item's bitstreams and bundles have been saved.",
|
||||
|
||||
"item.edit.bitstreams.notifications.move.saved.title": "Move changes saved",
|
||||
|
||||
"item.edit.bitstreams.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts",
|
||||
|
||||
"item.edit.bitstreams.notifications.outdated.title": "Changes outdated",
|
||||
|
||||
"item.edit.bitstreams.notifications.remove.failed.title": "Error deleting bitstream",
|
||||
|
||||
"item.edit.bitstreams.notifications.remove.saved.content": "Your removal changes to this item's bitstreams have been saved.",
|
||||
|
||||
"item.edit.bitstreams.notifications.remove.saved.title": "Removal changes saved",
|
||||
|
||||
"item.edit.bitstreams.reinstate-button": "Undo",
|
||||
|
||||
"item.edit.bitstreams.save-button": "Save",
|
||||
|
||||
"item.edit.bitstreams.upload-button": "Upload",
|
||||
|
||||
|
||||
|
||||
"item.edit.delete.cancel": "Cancel",
|
||||
|
||||
"item.edit.delete.confirm": "Delete",
|
||||
@@ -943,7 +1259,7 @@
|
||||
|
||||
|
||||
|
||||
"item.edit.tabs.bitstreams.head": "Item Bitstreams",
|
||||
"item.edit.tabs.bitstreams.head": "Bitstreams",
|
||||
|
||||
"item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams",
|
||||
|
||||
@@ -951,11 +1267,11 @@
|
||||
|
||||
"item.edit.tabs.curate.title": "Item Edit - Curate",
|
||||
|
||||
"item.edit.tabs.metadata.head": "Item Metadata",
|
||||
"item.edit.tabs.metadata.head": "Metadata",
|
||||
|
||||
"item.edit.tabs.metadata.title": "Item Edit - Metadata",
|
||||
|
||||
"item.edit.tabs.relationships.head": "Item Relationships",
|
||||
"item.edit.tabs.relationships.head": "Relationships",
|
||||
|
||||
"item.edit.tabs.relationships.title": "Item Edit - Relationships",
|
||||
|
||||
@@ -993,7 +1309,7 @@
|
||||
|
||||
"item.edit.tabs.status.description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.",
|
||||
|
||||
"item.edit.tabs.status.head": "Item Status",
|
||||
"item.edit.tabs.status.head": "Status",
|
||||
|
||||
"item.edit.tabs.status.labels.handle": "Handle",
|
||||
|
||||
@@ -1162,6 +1478,10 @@
|
||||
|
||||
|
||||
|
||||
"loading.bitstream": "Loading bitstream...",
|
||||
|
||||
"loading.bitstreams": "Loading bitstreams...",
|
||||
|
||||
"loading.browse-by": "Loading items...",
|
||||
|
||||
"loading.browse-by-page": "Loading page...",
|
||||
@@ -2207,6 +2527,9 @@
|
||||
|
||||
"administrativeView.search.results.head": "Administrative Search",
|
||||
|
||||
"menu.section.admin_search": "Admin Search",
|
||||
|
||||
|
||||
|
||||
"uploader.browse": "browse",
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3772
resources/i18n/lv.json5
Normal file
3772
resources/i18n/lv.json5
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,32 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||
import { getAccessControlModulePath } from '../admin-routing.module';
|
||||
|
||||
const GROUP_EDIT_PATH = 'groups';
|
||||
|
||||
export function getGroupEditPath(id: string) {
|
||||
return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString();
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } },
|
||||
{ path: GROUP_EDIT_PATH, component: GroupsRegistryComponent, data: { title: 'admin.access-control.groups.title' } },
|
||||
{
|
||||
path: `${GROUP_EDIT_PATH}/:groupId`,
|
||||
component: GroupFormComponent,
|
||||
data: {title: 'admin.registries.schema.title'}
|
||||
},
|
||||
{
|
||||
path: `${GROUP_EDIT_PATH}/newGroup`,
|
||||
component: GroupFormComponent,
|
||||
data: {title: 'admin.registries.schema.title'}
|
||||
},
|
||||
])
|
||||
]
|
||||
})
|
||||
|
@@ -6,6 +6,10 @@ import { SharedModule } from '../../shared/shared.module';
|
||||
import { AdminAccessControlRoutingModule } from './admin-access-control-routing.module';
|
||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
||||
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
|
||||
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
|
||||
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -17,7 +21,11 @@ import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-fo
|
||||
],
|
||||
declarations: [
|
||||
EPeopleRegistryComponent,
|
||||
EPersonFormComponent
|
||||
EPersonFormComponent,
|
||||
GroupsRegistryComponent,
|
||||
GroupFormComponent,
|
||||
SubgroupsListComponent,
|
||||
MembersListComponent
|
||||
],
|
||||
entryComponents: []
|
||||
})
|
||||
|
@@ -15,7 +15,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}</h3>
|
||||
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
|
||||
<button (click)="clearFormAndResetResult();"
|
||||
class="btn btn-primary float-right">{{labelPrefix + 'button.see-all' | translate}}</button>
|
||||
</h3>
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||
<div class="col-12 col-sm-3">
|
||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||
@@ -64,12 +67,12 @@
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="toggleEditEPerson(eperson)"
|
||||
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate}}">
|
||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: eperson.name} }}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button (click)="deleteEPerson(eperson)"
|
||||
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate}}">
|
||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: eperson.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { Router } from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
@@ -20,6 +21,7 @@ import { getMockTranslateService } from '../../../shared/mocks/mock-translate.se
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson-mock';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
|
||||
import { EPeopleRegistryComponent } from './epeople-registry.component';
|
||||
|
||||
@@ -29,10 +31,11 @@ describe('EPeopleRegistryComponent', () => {
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
|
||||
const mockEPeople = [EPersonMock, EPersonMock2];
|
||||
let mockEPeople;
|
||||
let ePersonDataServiceStub: any;
|
||||
|
||||
beforeEach(async(() => {
|
||||
mockEPeople = [EPersonMock, EPersonMock2];
|
||||
ePersonDataServiceStub = {
|
||||
activeEPerson: null,
|
||||
allEpeople: mockEPeople,
|
||||
@@ -50,6 +53,9 @@ describe('EPeopleRegistryComponent', () => {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||
}
|
||||
if (scope === 'metadata') {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
|
||||
}
|
||||
const result = this.allEpeople.find((ePerson: EPerson) => {
|
||||
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
||||
});
|
||||
@@ -71,6 +77,9 @@ describe('EPeopleRegistryComponent', () => {
|
||||
},
|
||||
clearEPersonRequests(): void {
|
||||
// empty
|
||||
},
|
||||
getEPeoplePageRouterLink(): string {
|
||||
return '/admin/access-control/epeople';
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
@@ -89,7 +98,7 @@ describe('EPeopleRegistryComponent', () => {
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
EPeopleRegistryComponent
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
@@ -12,152 +14,189 @@ import { NotificationsService } from '../../../shared/notifications/notification
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-epeople-registry',
|
||||
templateUrl: './epeople-registry.component.html',
|
||||
selector: 'ds-epeople-registry',
|
||||
templateUrl: './epeople-registry.component.html',
|
||||
})
|
||||
/**
|
||||
* A component used for managing all existing epeople within the repository.
|
||||
* The admin can create, edit or delete epeople here.
|
||||
*/
|
||||
export class EPeopleRegistryComponent {
|
||||
export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
|
||||
labelPrefix = 'admin.access-control.epeople.';
|
||||
labelPrefix = 'admin.access-control.epeople.';
|
||||
|
||||
/**
|
||||
* A list of all the current EPeople within the repository or the result of the search
|
||||
*/
|
||||
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||
/**
|
||||
* A list of all the current EPeople within the repository or the result of the search
|
||||
*/
|
||||
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of epeople
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'epeople-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
/**
|
||||
* Pagination config used to display the list of epeople
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'epeople-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether or not to show the EPerson form
|
||||
*/
|
||||
isEPersonFormShown: boolean;
|
||||
|
||||
// The search form
|
||||
searchForm;
|
||||
|
||||
// Current search in epersons registry
|
||||
currentSearchQuery: string;
|
||||
currentSearchScope: string;
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(private epersonService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router) {
|
||||
this.currentSearchQuery = '';
|
||||
this.currentSearchScope = 'metadata';
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
scope: 'metadata',
|
||||
query: '',
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
if (eperson != null && eperson.id) {
|
||||
this.isEPersonFormShown = true;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateEPeople() {
|
||||
this.epersonService.clearEPersonRequests();
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ query: '', scope: 'metadata' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in the EPeople by metadata (default) or email
|
||||
* @param data Contains scope and query param
|
||||
*/
|
||||
search(data: any) {
|
||||
const query: string = data.query;
|
||||
const scope: string = data.scope;
|
||||
if (query != null && this.currentSearchQuery !== query) {
|
||||
this.router.navigateByUrl(this.epersonService.getEPeoplePageRouterLink());
|
||||
this.currentSearchQuery = query;
|
||||
this.config.currentPage = 1;
|
||||
}
|
||||
if (scope != null && this.currentSearchScope !== scope) {
|
||||
this.router.navigateByUrl(this.epersonService.getEPeoplePageRouterLink());
|
||||
this.currentSearchScope = scope;
|
||||
this.config.currentPage = 1;
|
||||
}
|
||||
this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||
currentPage: this.config.currentPage,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not to show the EPerson form
|
||||
*/
|
||||
isEPersonFormShown: boolean;
|
||||
/**
|
||||
* Checks whether the given EPerson is active (being edited)
|
||||
* @param eperson
|
||||
*/
|
||||
isActive(eperson: EPerson): Observable<boolean> {
|
||||
return this.getActiveEPerson().pipe(
|
||||
map((activeEPerson) => eperson === activeEPerson)
|
||||
);
|
||||
}
|
||||
|
||||
// The search form
|
||||
searchForm;
|
||||
/**
|
||||
* Gets the active eperson (being edited)
|
||||
*/
|
||||
getActiveEPerson(): Observable<EPerson> {
|
||||
return this.epersonService.getActiveEPerson();
|
||||
}
|
||||
|
||||
constructor(private epersonService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private formBuilder: FormBuilder) {
|
||||
this.updateEPeople({
|
||||
currentPage: 1,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
/**
|
||||
* Start editing the selected EPerson
|
||||
* @param ePerson
|
||||
*/
|
||||
toggleEditEPerson(ePerson: EPerson) {
|
||||
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
||||
if (ePerson === activeEPerson) {
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
scope: 'metadata',
|
||||
query: '',
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.epersonService.editEPerson(ePerson);
|
||||
this.isEPersonFormShown = true;
|
||||
}
|
||||
});
|
||||
this.scrollToTop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.updateEPeople({
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of EPeople by fetching it from the rest api or cache
|
||||
*/
|
||||
private updateEPeople(options) {
|
||||
this.ePeople = this.epersonService.getEPeople(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateEPeople() {
|
||||
this.epersonService.clearEPersonRequests();
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ query: '', scope: 'metadata' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in the EPeople by metadata (default) or email
|
||||
* @param data Contains scope and query param
|
||||
*/
|
||||
search(data: any) {
|
||||
this.ePeople = this.epersonService.searchByScope(data.scope, data.query, {
|
||||
currentPage: 1,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given EPerson is active (being edited)
|
||||
* @param eperson
|
||||
*/
|
||||
isActive(eperson: EPerson): Observable<boolean> {
|
||||
return this.getActiveEPerson().pipe(
|
||||
map((activeEPerson) => eperson === activeEPerson)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active eperson (being edited)
|
||||
*/
|
||||
getActiveEPerson(): Observable<EPerson> {
|
||||
return this.epersonService.getActiveEPerson();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the selected EPerson
|
||||
* @param ePerson
|
||||
*/
|
||||
toggleEditEPerson(ePerson: EPerson) {
|
||||
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
||||
if (ePerson === activeEPerson) {
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
} else {
|
||||
this.epersonService.editEPerson(ePerson);
|
||||
this.isEPersonFormShown = true;
|
||||
}
|
||||
});
|
||||
this.scrollToTop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
||||
*/
|
||||
deleteEPerson(ePerson: EPerson) {
|
||||
if (hasValue(ePerson.id)) {
|
||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||
this.forceUpdateEPeople();
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
||||
}
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
})
|
||||
/**
|
||||
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
||||
*/
|
||||
deleteEPerson(ePerson: EPerson) {
|
||||
if (hasValue(ePerson.id)) {
|
||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||
this.forceUpdateEPeople();
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
||||
}
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
(function smoothscroll() {
|
||||
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
if (currentScroll > 0) {
|
||||
window.requestAnimationFrame(smoothscroll);
|
||||
window.scrollTo(0, currentScroll - (currentScroll / 8));
|
||||
}
|
||||
})();
|
||||
}
|
||||
/**
|
||||
* Unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
(function smoothscroll() {
|
||||
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
if (currentScroll > 0) {
|
||||
window.requestAnimationFrame(smoothscroll);
|
||||
window.scrollTo(0, currentScroll - (currentScroll / 8));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty and search all search
|
||||
*/
|
||||
clearFormAndResetResult() {
|
||||
this.searchForm.patchValue({
|
||||
query: '',
|
||||
});
|
||||
this.search({ query: '' });
|
||||
}
|
||||
}
|
||||
|
@@ -26,7 +26,6 @@ const initialState: EPeopleRegistryState = {
|
||||
* @param action The EPeopleRegistryAction to perform on the state
|
||||
*/
|
||||
export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegistryAction): EPeopleRegistryState {
|
||||
|
||||
switch (action.type) {
|
||||
|
||||
case EPeopleRegistryActionTypes.EDIT_EPERSON: {
|
||||
|
@@ -15,3 +15,44 @@
|
||||
(cancel)="onCancel()"
|
||||
(submitForm)="onSubmit()">
|
||||
</ds-form>
|
||||
|
||||
<div *ngIf="epersonService.getActiveEPerson() | async">
|
||||
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(groups | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(groups | async)?.payload"
|
||||
[collectionSize]="(groups | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
|
||||
<div>
|
||||
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
|
||||
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
@@ -7,13 +9,18 @@ import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||
import { RestResponse } from '../../../../core/cache/response.models';
|
||||
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { FindListOptions } from '../../../../core/data/request.models';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
||||
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { UUIDService } from '../../../../core/shared/uuid.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { getMockFormBuilderService } from '../../../../shared/mocks/mock-form-builder-service';
|
||||
import { getMockTranslateService } from '../../../../shared/mocks/mock-translate.service';
|
||||
@@ -31,10 +38,11 @@ describe('EPersonFormComponent', () => {
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
|
||||
const mockEPeople = [EPersonMock, EPersonMock2];
|
||||
let mockEPeople;
|
||||
let ePersonDataServiceStub: any;
|
||||
|
||||
beforeEach(async(() => {
|
||||
mockEPeople = [EPersonMock, EPersonMock2];
|
||||
ePersonDataServiceStub = {
|
||||
activeEPerson: null,
|
||||
allEpeople: mockEPeople,
|
||||
@@ -52,6 +60,9 @@ describe('EPersonFormComponent', () => {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||
}
|
||||
if (scope === 'metadata') {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
|
||||
}
|
||||
const result = this.allEpeople.find((ePerson: EPerson) => {
|
||||
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
||||
});
|
||||
@@ -107,6 +118,13 @@ describe('EPersonFormComponent', () => {
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: ObjectCacheService, useValue: {} },
|
||||
{ provide: UUIDService, useValue: {} },
|
||||
{ provide: Store, useValue: {} },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
EPeopleRegistryComponent
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
@@ -116,7 +134,6 @@ describe('EPersonFormComponent', () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EPersonFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -125,30 +142,37 @@ describe('EPersonFormComponent', () => {
|
||||
}));
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
const firstName = 'testName';
|
||||
const lastName = 'testLastName';
|
||||
const email = 'testEmail@test.com';
|
||||
const canLogIn = false;
|
||||
const requireCertificate = false;
|
||||
let firstName;
|
||||
let lastName;
|
||||
let email;
|
||||
let canLogIn;
|
||||
let requireCertificate;
|
||||
|
||||
const expected = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: firstName
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: lastName
|
||||
},
|
||||
],
|
||||
},
|
||||
email: email,
|
||||
canLogIn: canLogIn,
|
||||
requireCertificate: requireCertificate,
|
||||
});
|
||||
let expected;
|
||||
beforeEach(() => {
|
||||
firstName = 'testName';
|
||||
lastName = 'testLastName';
|
||||
email = 'testEmail@test.com';
|
||||
canLogIn = false;
|
||||
requireCertificate = false;
|
||||
|
||||
expected = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: firstName
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: lastName
|
||||
},
|
||||
],
|
||||
},
|
||||
email: email,
|
||||
canLogIn: canLogIn,
|
||||
requireCertificate: requireCertificate,
|
||||
});
|
||||
spyOn(component.submitForm, 'emit');
|
||||
component.firstName.value = firstName;
|
||||
component.lastName.value = lastName;
|
||||
@@ -171,25 +195,26 @@ describe('EPersonFormComponent', () => {
|
||||
});
|
||||
|
||||
describe('with an active eperson', () => {
|
||||
const expectedWithId = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: firstName
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: lastName
|
||||
},
|
||||
],
|
||||
},
|
||||
email: email,
|
||||
canLogIn: canLogIn,
|
||||
requireCertificate: requireCertificate,
|
||||
});
|
||||
let expectedWithId;
|
||||
|
||||
beforeEach(() => {
|
||||
expectedWithId = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: firstName
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: lastName
|
||||
},
|
||||
],
|
||||
},
|
||||
email: email,
|
||||
canLogIn: canLogIn,
|
||||
requireCertificate: requireCertificate,
|
||||
});
|
||||
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
|
@@ -7,17 +7,21 @@ import {
|
||||
DynamicInputModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { Subscription, combineLatest } from 'rxjs';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { RestResponse } from '../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
||||
import { Group } from '../../../../core/eperson/models/group.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-eperson-form',
|
||||
@@ -106,12 +110,27 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* A list of all the groups this EPerson is a member of
|
||||
*/
|
||||
groups: Observable<RemoteData<PaginatedList<Group>>>;
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of groups
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'groups-ePersonMemberOf-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* Try to retrieve initial active eperson, to fill in checkboxes at component creation
|
||||
*/
|
||||
epersonInitial: EPerson;
|
||||
|
||||
constructor(public epersonService: EPersonDataService,
|
||||
public groupsDataService: GroupDataService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,) {
|
||||
@@ -181,6 +200,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
];
|
||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
if (eperson != null) {
|
||||
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, {
|
||||
currentPage: 1,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
this.formGroup.patchValue({
|
||||
firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '',
|
||||
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
|
||||
@@ -320,18 +345,25 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
clearFields() {
|
||||
this.formGroup.patchValue({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
canLogin: true,
|
||||
requireCertificate: false
|
||||
onPageChange(event) {
|
||||
this.updateGroups({
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of groups by fetching it from the rest api or cache
|
||||
*/
|
||||
private updateGroups(options) {
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||
*/
|
||||
|
@@ -0,0 +1,33 @@
|
||||
<div class="container">
|
||||
<div class="group-form row">
|
||||
<div class="col-12">
|
||||
|
||||
<div *ngIf="groupDataService.getActiveGroup() | async; then editheader; else createHeader"></div>
|
||||
|
||||
<ng-template #createHeader>
|
||||
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #editheader>
|
||||
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
|
||||
</ng-template>
|
||||
|
||||
<ds-form [formId]="formId"
|
||||
[formModel]="formModel"
|
||||
[formGroup]="formGroup"
|
||||
[formLayout]="formLayout"
|
||||
(cancel)="onCancel()"
|
||||
(submitForm)="onSubmit()">
|
||||
</ds-form>
|
||||
|
||||
<ds-members-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
|
||||
<ds-subgroups-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
|
||||
|
||||
<div>
|
||||
<button [routerLink]="[this.groupDataService.getGroupRegistryRouterLink()]"
|
||||
class="btn btn-primary">{{messagePrefix + '.return' | translate}}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,153 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||
import { RestResponse } from '../../../../core/cache/response.models';
|
||||
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||
import { Group } from '../../../../core/eperson/models/group.model';
|
||||
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { UUIDService } from '../../../../core/shared/uuid.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { getMockFormBuilderService } from '../../../../shared/mocks/mock-form-builder-service';
|
||||
import { MockRouter } from '../../../../shared/mocks/mock-router';
|
||||
import { getMockTranslateService } from '../../../../shared/mocks/mock-translate.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock';
|
||||
import { MockTranslateLoader } from '../../../../shared/testing/mock-translate-loader';
|
||||
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
|
||||
import { GroupFormComponent } from './group-form.component';
|
||||
|
||||
describe('GroupFormComponent', () => {
|
||||
let component: GroupFormComponent;
|
||||
let fixture: ComponentFixture<GroupFormComponent>;
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
let ePersonDataServiceStub: any;
|
||||
let groupsDataServiceStub: any;
|
||||
let router;
|
||||
|
||||
let groups;
|
||||
let groupName;
|
||||
let groupDescription;
|
||||
let expected;
|
||||
|
||||
beforeEach(async(() => {
|
||||
groups = [GroupMock, GroupMock2]
|
||||
groupName = 'testGroupName';
|
||||
groupDescription = 'testDescription';
|
||||
expected = Object.assign(new Group(), {
|
||||
name: groupName,
|
||||
metadata: {
|
||||
'dc.description': [
|
||||
{
|
||||
value: groupDescription
|
||||
}
|
||||
],
|
||||
},
|
||||
});
|
||||
ePersonDataServiceStub = {};
|
||||
groupsDataServiceStub = {
|
||||
allGroups: groups,
|
||||
activeGroup: null,
|
||||
getActiveGroup(): Observable<Group> {
|
||||
return observableOf(this.activeGroup);
|
||||
},
|
||||
getGroupRegistryRouterLink(): string {
|
||||
return '/admin/access-control/groups';
|
||||
},
|
||||
editGroup(group: Group) {
|
||||
this.activeGroup = group
|
||||
},
|
||||
cancelEditGroup(): void {
|
||||
this.activeGroup = null;
|
||||
},
|
||||
findById(id: string) {
|
||||
return observableOf({ payload: null, hasSucceeded: true });
|
||||
},
|
||||
tryToCreate(group: Group): Observable<RestResponse> {
|
||||
this.allGroups = [...this.allGroups, group]
|
||||
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||
},
|
||||
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
router = new MockRouter();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [GroupFormComponent],
|
||||
providers: [GroupFormComponent,
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: ObjectCacheService, useValue: {} },
|
||||
{ provide: UUIDService, useValue: {} },
|
||||
{ provide: Store, useValue: {} },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(GroupFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create GroupFormComponent', inject([GroupFormComponent], (comp: GroupFormComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(component.submitForm, 'emit');
|
||||
component.groupName.value = groupName;
|
||||
component.groupDescription.value = groupDescription;
|
||||
});
|
||||
describe('without active Group', () => {
|
||||
beforeEach(() => {
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit a new group using the correct values', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,280 @@
|
||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormLayout,
|
||||
DynamicInputModel,
|
||||
DynamicTextAreaModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { RestResponse } from '../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||
import { Group } from '../../../../core/eperson/models/group.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-group-form',
|
||||
templateUrl: './group-form.component.html'
|
||||
})
|
||||
/**
|
||||
* A form used for creating and editing groups
|
||||
*/
|
||||
export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
|
||||
messagePrefix = 'admin.access-control.groups.form';
|
||||
|
||||
/**
|
||||
* A unique id used for ds-form
|
||||
*/
|
||||
formId = 'group-form';
|
||||
|
||||
/**
|
||||
* Dynamic models for the inputs of form
|
||||
*/
|
||||
groupName: DynamicInputModel;
|
||||
groupDescription: DynamicTextAreaModel;
|
||||
|
||||
/**
|
||||
* A list of all dynamic input models
|
||||
*/
|
||||
formModel: DynamicFormControlModel[];
|
||||
|
||||
/**
|
||||
* Layout used for structuring the form inputs
|
||||
*/
|
||||
formLayout: DynamicFormLayout = {
|
||||
groupName: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
groupDescription: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A FormGroup that combines all inputs
|
||||
*/
|
||||
formGroup: FormGroup;
|
||||
|
||||
/**
|
||||
* An EventEmitter that's fired whenever the form is being submitted
|
||||
*/
|
||||
@Output() submitForm: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* An EventEmitter that's fired whenever the form is cancelled
|
||||
*/
|
||||
@Output() cancelForm: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Group currently being edited
|
||||
*/
|
||||
groupBeingEdited: Group;
|
||||
|
||||
constructor(public groupDataService: GroupDataService,
|
||||
private ePersonDataService: EPersonDataService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private route: ActivatedRoute,
|
||||
protected router: Router) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.subs.push(this.route.params.subscribe((params) => {
|
||||
this.setActiveGroup(params.groupId)
|
||||
}));
|
||||
combineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.groupName`),
|
||||
this.translateService.get(`${this.messagePrefix}.groupDescription`),
|
||||
).subscribe(([groupName, groupDescription]) => {
|
||||
this.groupName = new DynamicInputModel({
|
||||
id: 'groupName',
|
||||
label: groupName,
|
||||
name: 'groupName',
|
||||
validators: {
|
||||
required: null,
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
this.groupDescription = new DynamicTextAreaModel({
|
||||
id: 'groupDescription',
|
||||
label: groupDescription,
|
||||
name: 'groupDescription',
|
||||
required: false,
|
||||
});
|
||||
this.formModel = [
|
||||
this.groupName,
|
||||
this.groupDescription
|
||||
];
|
||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
this.groupBeingEdited = activeGroup;
|
||||
this.formGroup.patchValue({
|
||||
groupName: activeGroup != null ? activeGroup.name : '',
|
||||
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
|
||||
});
|
||||
if (activeGroup.permanent) {
|
||||
this.formGroup.get('groupName').disable();
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop editing the currently selected group
|
||||
*/
|
||||
onCancel() {
|
||||
this.groupDataService.cancelEditGroup();
|
||||
this.cancelForm.emit();
|
||||
this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form
|
||||
* When the eperson has an id attached -> Edit the eperson
|
||||
* When the eperson has no id attached -> Create new eperson
|
||||
* Emit the updated/created eperson using the EventEmitter submitForm
|
||||
*/
|
||||
onSubmit() {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe(
|
||||
(group: Group) => {
|
||||
const values = {
|
||||
name: this.groupName.value,
|
||||
metadata: {
|
||||
'dc.description': [
|
||||
{
|
||||
value: this.groupDescription.value
|
||||
}
|
||||
],
|
||||
},
|
||||
};
|
||||
if (group === null) {
|
||||
this.createNewGroup(values);
|
||||
} else {
|
||||
this.editGroup(group, values);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new Group based on given values from form
|
||||
* @param values
|
||||
*/
|
||||
createNewGroup(values) {
|
||||
const groupToCreate = Object.assign(new Group(), values);
|
||||
const response = this.groupDataService.tryToCreate(groupToCreate);
|
||||
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||
if (restResponse.isSuccessful) {
|
||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.created.success', { name: groupToCreate.name }));
|
||||
this.submitForm.emit(groupToCreate);
|
||||
const resp: any = restResponse;
|
||||
if (isNotEmpty(resp.resourceSelfLinks)) {
|
||||
const groupSelfLink = resp.resourceSelfLinks[0];
|
||||
this.setActiveGroupWithLink(groupSelfLink);
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(this.groupDataService.getUUIDFromString(groupSelfLink)));
|
||||
}
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name }));
|
||||
this.showNotificationIfNameInUse(groupToCreate, 'created');
|
||||
this.cancelForm.emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for the given group if there is already a group in the system with that group name and shows error if that
|
||||
* is the case
|
||||
* @param group group to check
|
||||
* @param notificationSection whether in create or edit
|
||||
*/
|
||||
private showNotificationIfNameInUse(group: Group, notificationSection: string) {
|
||||
// Relevant message for group name in use
|
||||
this.subs.push(this.groupDataService.searchGroups(group.name, {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 0
|
||||
}).pipe(getSucceededRemoteData(), getRemoteDataPayload())
|
||||
.subscribe((list: PaginatedList<Group>) => {
|
||||
if (list.totalElements > 0) {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', {
|
||||
name: group.name
|
||||
}));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* // TODO
|
||||
* @param group
|
||||
* @param values
|
||||
*/
|
||||
editGroup(group: Group, values) {
|
||||
// TODO (backend)
|
||||
console.log('TODO implement editGroup', values);
|
||||
this.notificationsService.error('TODO implement editGroup (not yet implemented in backend) ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the selected group
|
||||
* @param groupId ID of group to set as active
|
||||
*/
|
||||
setActiveGroup(groupId: string) {
|
||||
this.groupDataService.cancelEditGroup();
|
||||
this.groupDataService.findById(groupId)
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload())
|
||||
.subscribe((group: Group) => {
|
||||
this.groupDataService.editGroup(group);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the selected group
|
||||
* @param groupSelfLink SelfLink of group to set as active
|
||||
*/
|
||||
setActiveGroupWithLink(groupSelfLink: string) {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||
if (activeGroup === null) {
|
||||
this.groupDataService.cancelEditGroup();
|
||||
this.groupDataService.findByHref(groupSelfLink)
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload())
|
||||
.subscribe((group: Group) => {
|
||||
this.groupDataService.editGroup(group);
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||
*/
|
||||
@HostListener('window:beforeunload')
|
||||
ngOnDestroy(): void {
|
||||
this.onCancel();
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,124 @@
|
||||
<ng-container>
|
||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||
|
||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
||||
<button (click)="clearFormAndResetResult();"
|
||||
class="btn btn-primary float-right">{{messagePrefix + '.button.see-all' | translate}}</button>
|
||||
</h4>
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||
<div class="col-12 col-sm-3">
|
||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||
<option value="metadata">{{messagePrefix + '.search.scope.metadata' | translate}}</option>
|
||||
<option value="email">{{messagePrefix + '.search.scope.email' | translate}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-9 col-12">
|
||||
<div class="form-group input-group">
|
||||
<input type="text" name="query" id="query" formControlName="query"
|
||||
class="form-control" aria-label="Search input">
|
||||
<span class="input-group-append">
|
||||
<button type="submit"
|
||||
class="search-button btn btn-secondary">{{ messagePrefix + '.search.button' | translate }}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ds-pagination *ngIf="(ePeopleSearch | async)?.payload.totalElements > 0"
|
||||
[paginationOptions]="configSearch"
|
||||
[pageInfoState]="(ePeopleSearch | async)?.payload"
|
||||
[collectionSize]="(ePeopleSearch | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChangeSearch($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let ePerson of (ePeopleSearch | async)?.payload?.page">
|
||||
<td>{{ePerson.id}}</td>
|
||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson)"
|
||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.name}}</a></td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button *ngIf="(isMemberOfGroup(ePerson) | async)"
|
||||
(click)="deleteMemberFromGroup(ePerson)"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
|
||||
<button *ngIf="!(isMemberOfGroup(ePerson) | async)"
|
||||
(click)="addMemberToGroup(ePerson)"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.name} }}">
|
||||
<i class="fas fa-plus fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(ePeopleSearch | async)?.payload.totalElements == 0 && searchDone"
|
||||
class="alert alert-info w-100 mb-2"
|
||||
role="alert">
|
||||
{{messagePrefix + '.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
<h4>{{messagePrefix + '.headMembers' | translate}}</h4>
|
||||
|
||||
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.payload.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(ePeopleMembersOfGroup | async)?.payload"
|
||||
[collectionSize]="(ePeopleMembersOfGroup | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let ePerson of (ePeopleMembersOfGroup | async)?.payload?.page">
|
||||
<td>{{ePerson.id}}</td>
|
||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson)"
|
||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.name}}</a></td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="deleteMemberFromGroup(ePerson)"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(ePeopleMembersOfGroup | async)?.payload.totalElements == 0" class="alert alert-info w-100 mb-2"
|
||||
role="alert">
|
||||
{{messagePrefix + '.no-members-yet' | translate}}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@@ -0,0 +1,241 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||
import { EPerson } from '../../../../../core/eperson/models/eperson.model';
|
||||
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||
import { PageInfo } from '../../../../../core/shared/page-info.model';
|
||||
import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service';
|
||||
import { getMockFormBuilderService } from '../../../../../shared/mocks/mock-form-builder-service';
|
||||
import { MockRouter } from '../../../../../shared/mocks/mock-router';
|
||||
import { getMockTranslateService } from '../../../../../shared/mocks/mock-translate.service';
|
||||
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||
import { EPersonMock, EPersonMock2 } from '../../../../../shared/testing/eperson-mock';
|
||||
import { GroupMock, GroupMock2 } from '../../../../../shared/testing/group-mock';
|
||||
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
|
||||
import { NotificationsServiceStub } from '../../../../../shared/testing/notifications-service-stub';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||
import { MembersListComponent } from './members-list.component';
|
||||
|
||||
describe('MembersListComponent', () => {
|
||||
let component: MembersListComponent;
|
||||
let fixture: ComponentFixture<MembersListComponent>;
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
let ePersonDataServiceStub: any;
|
||||
let groupsDataServiceStub: any;
|
||||
let activeGroup;
|
||||
let allEPersons;
|
||||
let allGroups;
|
||||
let epersonMembers;
|
||||
let subgroupMembers;
|
||||
|
||||
beforeEach(async(() => {
|
||||
activeGroup = GroupMock;
|
||||
epersonMembers = [EPersonMock2];
|
||||
subgroupMembers = [GroupMock2];
|
||||
allEPersons = [EPersonMock, EPersonMock2];
|
||||
allGroups = [GroupMock, GroupMock2];
|
||||
ePersonDataServiceStub = {
|
||||
activeGroup: activeGroup,
|
||||
epersonMembers: epersonMembers,
|
||||
subgroupMembers: subgroupMembers,
|
||||
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()))
|
||||
},
|
||||
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allEPersons))
|
||||
}
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
},
|
||||
clearEPersonRequests() {
|
||||
// empty
|
||||
},
|
||||
clearLinkRequests() {
|
||||
// empty
|
||||
},
|
||||
getEPeoplePageRouterLink(): string {
|
||||
return '/admin/access-control/epeople';
|
||||
}
|
||||
};
|
||||
groupsDataServiceStub = {
|
||||
activeGroup: activeGroup,
|
||||
epersonMembers: epersonMembers,
|
||||
subgroupMembers: subgroupMembers,
|
||||
allGroups: allGroups,
|
||||
getActiveGroup(): Observable<Group> {
|
||||
return observableOf(activeGroup);
|
||||
},
|
||||
getEPersonMembers() {
|
||||
return this.epersonMembers;
|
||||
},
|
||||
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this.allGroups))
|
||||
}
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
},
|
||||
addMemberToGroup(parentGroup, eperson: EPerson): Observable<RestResponse> {
|
||||
this.epersonMembers = [...this.epersonMembers, eperson];
|
||||
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||
},
|
||||
clearGroupsRequests() {
|
||||
// empty
|
||||
},
|
||||
clearGroupLinkRequests() {
|
||||
// empty
|
||||
},
|
||||
getGroupEditPageRouterLink(group: Group): string {
|
||||
return '/admin/access-control/groups/' + group.id;
|
||||
},
|
||||
deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> {
|
||||
this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => {
|
||||
if (eperson.id !== epersonToDelete.id) {
|
||||
return eperson;
|
||||
}
|
||||
});
|
||||
if (this.epersonMembers === undefined) {
|
||||
this.epersonMembers = []
|
||||
}
|
||||
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [MembersListComponent],
|
||||
providers: [MembersListComponent,
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: Router, useValue: new MockRouter() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MembersListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
afterEach(fakeAsync(() => {
|
||||
fixture.destroy();
|
||||
flush();
|
||||
component = null;
|
||||
}));
|
||||
|
||||
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
|
||||
it('should show list of eperson members of current active group', () => {
|
||||
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
|
||||
expect(epersonIdsFound.length).toEqual(1);
|
||||
epersonMembers.map((eperson: EPerson) => {
|
||||
expect(epersonIdsFound.find((foundEl) => {
|
||||
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
|
||||
})).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
describe('when searching without query', () => {
|
||||
let epersonsFound;
|
||||
beforeEach(fakeAsync(() => {
|
||||
component.search({ scope: 'metadata', query: '' });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||
}));
|
||||
|
||||
it('should display all epersons', () => {
|
||||
expect(epersonsFound.length).toEqual(2);
|
||||
});
|
||||
|
||||
describe('if eperson is already a eperson', () => {
|
||||
it('should have delete button, else it should have add button', () => {
|
||||
activeGroup.epersons.map((eperson: EPerson) => {
|
||||
epersonsFound.map((foundEPersonRowElement) => {
|
||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||
const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child'));
|
||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||
if (epersonId.nativeElement.textContent === eperson.id) {
|
||||
expect(addButton).toBeUndefined();
|
||||
expect(deleteButton).toBeDefined();
|
||||
} else {
|
||||
expect(deleteButton).toBeUndefined();
|
||||
expect(addButton).toBeDefined();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('if first add button is pressed', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
|
||||
addButton.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('all groups in search member of selected group', () => {
|
||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||
expect(epersonsFound.length).toEqual(2);
|
||||
epersonsFound.map((foundEPersonRowElement) => {
|
||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||
expect(addButton).toBeUndefined();
|
||||
expect(deleteButton).toBeDefined();
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('if first delete button is pressed', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
|
||||
addButton.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('first eperson in search delete button, because now member', () => {
|
||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||
epersonsFound.map((foundEPersonRowElement) => {
|
||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
expect(addButton).toBeDefined();
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,247 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { map, mergeMap, take } from 'rxjs/operators';
|
||||
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||
import { EPerson } from '../../../../../core/eperson/models/eperson.model';
|
||||
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators';
|
||||
import { hasValue } from '../../../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-members-list',
|
||||
templateUrl: './members-list.component.html'
|
||||
})
|
||||
/**
|
||||
* The list of members in the edit group page
|
||||
*/
|
||||
export class MembersListComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input()
|
||||
messagePrefix: string;
|
||||
|
||||
/**
|
||||
* EPeople being displayed in search result, initially all members, after search result of search
|
||||
*/
|
||||
ePeopleSearch: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||
/**
|
||||
* List of EPeople members of currently active group being edited
|
||||
*/
|
||||
ePeopleMembersOfGroup: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of EPeople that are result of EPeople search
|
||||
*/
|
||||
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'search-members-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
/**
|
||||
* Pagination config used to display the list of EPerson Membes of active group being edited
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'members-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
// The search form
|
||||
searchForm;
|
||||
|
||||
// Current search in edit group - epeople search form
|
||||
currentSearchQuery: string;
|
||||
currentSearchScope: string;
|
||||
|
||||
// Whether or not user has done a EPeople search yet
|
||||
searchDone: boolean;
|
||||
|
||||
// current active group being edited
|
||||
groupBeingEdited: Group;
|
||||
|
||||
constructor(private groupDataService: GroupDataService,
|
||||
public ePersonDataService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router) {
|
||||
this.currentSearchQuery = '';
|
||||
this.currentSearchScope = 'metadata';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
scope: 'metadata',
|
||||
query: '',
|
||||
}));
|
||||
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
this.groupBeingEdited = activeGroup;
|
||||
this.forceUpdateEPeople(activeGroup);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page on search result
|
||||
* @param event
|
||||
*/
|
||||
onPageChangeSearch(event) {
|
||||
this.configSearch.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page on EPerson embers of active group
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.ePeopleMembersOfGroup = this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, {
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a given EPerson from the members list of the group currently being edited
|
||||
* @param ePerson EPerson we want to delete as member from group that is currently being edited
|
||||
*/
|
||||
deleteMemberFromGroup(ePerson: EPerson) {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson);
|
||||
this.showNotifications('deleteMember', response, ePerson.name, activeGroup);
|
||||
this.forceUpdateEPeople(activeGroup);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a given EPerson to the members list of the group currently being edited
|
||||
* @param ePerson EPerson we want to add as member to group that is currently being edited
|
||||
*/
|
||||
addMemberToGroup(ePerson: EPerson) {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson);
|
||||
this.showNotifications('addMember', response, ePerson.name, activeGroup);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||
}
|
||||
});
|
||||
this.forceUpdateEPeople(this.groupBeingEdited, ePerson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the given ePerson is a member of the group currently being edited
|
||||
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
|
||||
*/
|
||||
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
|
||||
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||
mergeMap((group: Group) => {
|
||||
if (group != null) {
|
||||
return this.ePersonDataService.findAllByHref(group._links.epersons.href, {
|
||||
currentPage: 0,
|
||||
elementsPerPage: Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((listEPeopleInGroup: PaginatedList<EPerson>) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)),
|
||||
map((epeople: EPerson[]) => epeople.length > 0))
|
||||
} else {
|
||||
return observableOf(false);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in the EPeople by name, email or metadata
|
||||
* @param data Contains scope and query param
|
||||
*/
|
||||
search(data: any) {
|
||||
const query: string = data.query;
|
||||
const scope: string = data.scope;
|
||||
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||
this.currentSearchQuery = query;
|
||||
this.configSearch.currentPage = 1;
|
||||
}
|
||||
if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) {
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||
this.currentSearchScope = scope;
|
||||
this.configSearch.currentPage = 1;
|
||||
}
|
||||
this.searchDone = true;
|
||||
this.ePeopleSearch = this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||
currentPage: this.configSearch.currentPage,
|
||||
elementsPerPage: this.configSearch.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||
* a new REST call
|
||||
* @param activeGroup Group currently being edited
|
||||
*/
|
||||
public forceUpdateEPeople(activeGroup: Group, ePersonToUpdate?: EPerson) {
|
||||
if (ePersonToUpdate != null) {
|
||||
this.ePersonDataService.clearLinkRequests(ePersonToUpdate._links.groups.href);
|
||||
}
|
||||
this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href);
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(activeGroup));
|
||||
this.ePeopleMembersOfGroup = this.ePersonDataService.findAllByHref(activeGroup._links.epersons.href, {
|
||||
currentPage: this.configSearch.currentPage,
|
||||
elementsPerPage: this.configSearch.pageSize
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification based on the success/failure of the request
|
||||
* @param messageSuffix Suffix for message
|
||||
* @param response RestResponse observable containing success/failure request
|
||||
* @param nameObject Object request was about
|
||||
* @param activeGroup Group currently being edited
|
||||
*/
|
||||
showNotifications(messageSuffix: string, response: Observable<RestResponse>, nameObject: string, activeGroup: Group) {
|
||||
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||
if (restResponse.isSuccessful) {
|
||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty and search all search
|
||||
*/
|
||||
clearFormAndResetResult() {
|
||||
this.searchForm.patchValue({
|
||||
query: '',
|
||||
});
|
||||
this.search({ query: '' });
|
||||
}
|
||||
}
|
@@ -0,0 +1,117 @@
|
||||
<ng-container>
|
||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||
|
||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
||||
<button (click)="clearFormAndResetResult();"
|
||||
class="btn btn-primary float-right">{{messagePrefix + '.button.see-all' | translate}}</button>
|
||||
</h4>
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-group input-group">
|
||||
<input type="text" name="query" id="query" formControlName="query"
|
||||
class="form-control" aria-label="Search input">
|
||||
<span class="input-group-append">
|
||||
<button type="submit"
|
||||
class="search-button btn btn-secondary">{{ messagePrefix + '.search.button' | translate }}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ds-pagination *ngIf="(groupsSearch | async)?.payload.totalElements > 0"
|
||||
[paginationOptions]="configSearch"
|
||||
[pageInfoState]="(groupsSearch | async)?.payload"
|
||||
[collectionSize]="(groupsSearch | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChangeSearch($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (groupsSearch | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||
(click)="deleteSubgroupFromGroup(group)"
|
||||
class="btn btn-outline-danger btn-sm deleteButton"
|
||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
|
||||
<p *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</p>
|
||||
|
||||
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||
(click)="addSubgroupToGroup(group)"
|
||||
class="btn btn-outline-primary btn-sm addButton"
|
||||
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: group.name} }}">
|
||||
<i class="fas fa-plus fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(groupsSearch | async)?.payload.totalElements == 0 && searchDone" class="alert alert-info w-100 mb-2"
|
||||
role="alert">
|
||||
{{messagePrefix + '.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
|
||||
|
||||
<ds-pagination *ngIf="(subgroupsOfGroup | async)?.payload.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(subgroupsOfGroup | async)?.payload"
|
||||
[collectionSize]="(subgroupsOfGroup | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (subgroupsOfGroup | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="deleteSubgroupFromGroup(group)"
|
||||
class="btn btn-outline-danger btn-sm deleteButton"
|
||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(subgroupsOfGroup | async)?.payload.totalElements == 0" class="alert alert-info w-100 mb-2"
|
||||
role="alert">
|
||||
{{messagePrefix + '.no-subgroups-yet' | translate}}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@@ -0,0 +1,208 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||
import { PageInfo } from '../../../../../core/shared/page-info.model';
|
||||
import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service';
|
||||
import { getMockFormBuilderService } from '../../../../../shared/mocks/mock-form-builder-service';
|
||||
import { MockRouter } from '../../../../../shared/mocks/mock-router';
|
||||
import { getMockTranslateService } from '../../../../../shared/mocks/mock-translate.service';
|
||||
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||
import { GroupMock, GroupMock2 } from '../../../../../shared/testing/group-mock';
|
||||
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
|
||||
import { NotificationsServiceStub } from '../../../../../shared/testing/notifications-service-stub';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||
import { SubgroupsListComponent } from './subgroups-list.component';
|
||||
|
||||
describe('SubgroupsListComponent', () => {
|
||||
let component: SubgroupsListComponent;
|
||||
let fixture: ComponentFixture<SubgroupsListComponent>;
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
let ePersonDataServiceStub: any;
|
||||
let groupsDataServiceStub: any;
|
||||
let activeGroup;
|
||||
let subgroups;
|
||||
let allGroups;
|
||||
let routerStub;
|
||||
|
||||
beforeEach(async(() => {
|
||||
activeGroup = GroupMock;
|
||||
subgroups = [GroupMock2];
|
||||
allGroups = [GroupMock, GroupMock2];
|
||||
ePersonDataServiceStub = {};
|
||||
groupsDataServiceStub = {
|
||||
activeGroup: activeGroup,
|
||||
subgroups: subgroups,
|
||||
getActiveGroup(): Observable<Group> {
|
||||
return observableOf(this.activeGroup);
|
||||
},
|
||||
getSubgroups(): Group {
|
||||
return this.activeGroup;
|
||||
},
|
||||
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList<Group>(new PageInfo(), this.subgroups))
|
||||
},
|
||||
getGroupEditPageRouterLink(group: Group): string {
|
||||
return '/admin/access-control/groups/' + group.id;
|
||||
},
|
||||
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allGroups))
|
||||
}
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
},
|
||||
addSubGroupToGroup(parentGroup, subgroup: Group): Observable<RestResponse> {
|
||||
this.subgroups = [...this.subgroups, subgroup];
|
||||
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||
},
|
||||
clearGroupsRequests() {
|
||||
// empty
|
||||
},
|
||||
clearGroupLinkRequests() {
|
||||
// empty
|
||||
},
|
||||
deleteSubGroupFromGroup(parentGroup, subgroup: Group): Observable<RestResponse> {
|
||||
this.subgroups = this.subgroups.find((group: Group) => {
|
||||
if (group.id !== subgroup.id) {
|
||||
return group;
|
||||
}
|
||||
});
|
||||
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||
}
|
||||
};
|
||||
routerStub = new MockRouter();
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [SubgroupsListComponent],
|
||||
providers: [SubgroupsListComponent,
|
||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SubgroupsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
afterEach(fakeAsync(() => {
|
||||
fixture.destroy();
|
||||
flush();
|
||||
component = null;
|
||||
}));
|
||||
|
||||
it('should create SubgroupsListComponent', inject([SubgroupsListComponent], (comp: SubgroupsListComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
|
||||
it('should show list of subgroups of current active group', () => {
|
||||
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
|
||||
expect(groupIdsFound.length).toEqual(1);
|
||||
activeGroup.subgroups.map((group: Group) => {
|
||||
expect(groupIdsFound.find((foundEl) => {
|
||||
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||
})).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
describe('if first group delete button is pressed', () => {
|
||||
let groupsFound;
|
||||
beforeEach(fakeAsync(() => {
|
||||
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
|
||||
addButton.triggerEventHandler('click', {
|
||||
preventDefault: () => {/**/
|
||||
}
|
||||
});
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('one less subgroup in list from 1 to 0 (of 2 total groups)', () => {
|
||||
groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
|
||||
expect(groupsFound.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
describe('when searching with empty query', () => {
|
||||
let groupsFound;
|
||||
beforeEach(fakeAsync(() => {
|
||||
component.search({ query: '' });
|
||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||
}));
|
||||
|
||||
it('should display all groups', () => {
|
||||
fixture.detectChanges();
|
||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||
expect(groupsFound.length).toEqual(2);
|
||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
|
||||
allGroups.map((group: Group) => {
|
||||
expect(groupIdsFound.find((foundEl) => {
|
||||
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||
})).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
describe('if group is already a subgroup', () => {
|
||||
it('should have delete button, else it should have add button', () => {
|
||||
fixture.detectChanges();
|
||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
|
||||
if (getSubgroups !== undefined && getSubgroups.length > 0) {
|
||||
groupsFound.map((foundGroupRowElement) => {
|
||||
if (foundGroupRowElement.debugElement !== undefined) {
|
||||
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||
expect(addButton).toBeUndefined();
|
||||
expect(deleteButton).toBeDefined();
|
||||
}
|
||||
})
|
||||
} else {
|
||||
getSubgroups.map((group: Group) => {
|
||||
groupsFound.map((foundGroupRowElement) => {
|
||||
if (foundGroupRowElement.debugElement !== undefined) {
|
||||
const groupId = foundGroupRowElement.debugElement.query(By.css('td:first-child'));
|
||||
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||
if (groupId.nativeElement.textContent === group.id) {
|
||||
expect(addButton).toBeUndefined();
|
||||
expect(deleteButton).toBeDefined();
|
||||
} else {
|
||||
expect(deleteButton).toBeUndefined();
|
||||
expect(addButton).toBeDefined();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,253 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { map, mergeMap, take } from 'rxjs/operators';
|
||||
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators';
|
||||
import { hasValue } from '../../../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-subgroups-list',
|
||||
templateUrl: './subgroups-list.component.html'
|
||||
})
|
||||
/**
|
||||
* The list of subgroups in the edit group page
|
||||
*/
|
||||
export class SubgroupsListComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input()
|
||||
messagePrefix: string;
|
||||
|
||||
/**
|
||||
* Result of search groups, initially all groups
|
||||
*/
|
||||
groupsSearch: Observable<RemoteData<PaginatedList<Group>>>;
|
||||
/**
|
||||
* List of all subgroups of group being edited
|
||||
*/
|
||||
subgroupsOfGroup: Observable<RemoteData<PaginatedList<Group>>>;
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of groups that are result of groups search
|
||||
*/
|
||||
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'search-subgroups-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
/**
|
||||
* Pagination config used to display the list of subgroups of currently active group being edited
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'subgroups-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
// The search form
|
||||
searchForm;
|
||||
|
||||
// Current search in edit group - groups search form
|
||||
currentSearchQuery: string;
|
||||
|
||||
// Whether or not user has done a Groups search yet
|
||||
searchDone: boolean;
|
||||
|
||||
// current active group being edited
|
||||
groupBeingEdited: Group;
|
||||
|
||||
constructor(public groupDataService: GroupDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router) {
|
||||
this.currentSearchQuery = '';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
query: '',
|
||||
}));
|
||||
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
this.groupBeingEdited = activeGroup;
|
||||
this.forceUpdateGroups(activeGroup);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page on search result
|
||||
* @param event
|
||||
*/
|
||||
onPageChangeSearch(event) {
|
||||
this.configSearch.currentPage = event;
|
||||
this.search({ query: this.currentSearchQuery });
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page on subgroups of active group
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.subgroupsOfGroup = this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the given group is a subgroup of the group currently being edited
|
||||
* @param possibleSubgroup Group that is a possible subgroup (being tested) of the group currently being edited
|
||||
*/
|
||||
isSubgroupOfGroup(possibleSubgroup: Group): Observable<boolean> {
|
||||
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||
mergeMap((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
if (activeGroup.uuid === possibleSubgroup.uuid) {
|
||||
return observableOf(false);
|
||||
} else {
|
||||
return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, {
|
||||
currentPage: 0,
|
||||
elementsPerPage: Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((listTotalGroups: PaginatedList<Group>) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)),
|
||||
map((groups: Group[]) => groups.length > 0))
|
||||
}
|
||||
} else {
|
||||
return observableOf(false);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the given group is the current group being edited
|
||||
* @param group Group that is possibly the current group being edited
|
||||
*/
|
||||
isActiveGroup(group: Group): Observable<boolean> {
|
||||
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||
mergeMap((activeGroup: Group) => {
|
||||
if (activeGroup != null && activeGroup.uuid === group.uuid) {
|
||||
return observableOf(true);
|
||||
}
|
||||
return observableOf(false);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes given subgroup from the group currently being edited
|
||||
* @param subgroup Group we want to delete from the subgroups of the group currently being edited
|
||||
*/
|
||||
deleteSubgroupFromGroup(subgroup: Group) {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
|
||||
this.showNotifications('deleteSubgroup', response, subgroup.name, activeGroup);
|
||||
this.forceUpdateGroups(activeGroup);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds given subgroup to the group currently being edited
|
||||
* @param subgroup Subgroup to add to group currently being edited
|
||||
*/
|
||||
addSubgroupToGroup(subgroup: Group) {
|
||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||
if (activeGroup != null) {
|
||||
if (activeGroup.uuid !== subgroup.uuid) {
|
||||
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
|
||||
this.showNotifications('addSubgroup', response, subgroup.name, activeGroup);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
|
||||
}
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||
}
|
||||
});
|
||||
this.forceUpdateGroups(this.groupBeingEdited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in the groups (searches by group name and by uuid exact match)
|
||||
* @param data Contains query param
|
||||
*/
|
||||
search(data: any) {
|
||||
const query: string = data.query;
|
||||
if (query != null && this.currentSearchQuery !== query) {
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||
this.currentSearchQuery = query;
|
||||
this.configSearch.currentPage = 1;
|
||||
}
|
||||
this.searchDone = true;
|
||||
this.groupsSearch = this.groupDataService.searchGroups(this.currentSearchQuery, {
|
||||
currentPage: this.configSearch.currentPage,
|
||||
elementsPerPage: this.configSearch.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of groups by first clearing the cache of results of this active groups' subgroups, then performing a new REST call
|
||||
* @param activeGroup Group currently being edited
|
||||
*/
|
||||
public forceUpdateGroups(activeGroup: Group) {
|
||||
this.groupDataService.clearGroupLinkRequests(activeGroup._links.subgroups.href);
|
||||
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(activeGroup));
|
||||
this.subgroupsOfGroup = this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, {
|
||||
currentPage: this.config.currentPage,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification based on the success/failure of the request
|
||||
* @param messageSuffix Suffix for message
|
||||
* @param response RestResponse observable containing success/failure request
|
||||
* @param nameObject Object request was about
|
||||
* @param activeGroup Group currently being edited
|
||||
*/
|
||||
showNotifications(messageSuffix: string, response: Observable<RestResponse>, nameObject: string, activeGroup: Group) {
|
||||
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||
if (restResponse.isSuccessful) {
|
||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty and search all search
|
||||
*/
|
||||
clearFormAndResetResult() {
|
||||
this.searchForm.patchValue({
|
||||
query: '',
|
||||
});
|
||||
this.search({ query: '' });
|
||||
}
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Group } from '../../../core/eperson/models/group.model';
|
||||
import { type } from '../../../shared/ngrx/type';
|
||||
|
||||
/**
|
||||
* For each action type in an action group, make a simple
|
||||
* enum object for all of this group's action types.
|
||||
*
|
||||
* The 'type' utility function coerces strings into string
|
||||
* literal types and runs a simple check to guarantee all
|
||||
* action types in the application are unique.
|
||||
*/
|
||||
export const GroupRegistryActionTypes = {
|
||||
|
||||
EDIT_GROUP: type('dspace/epeople-registry/EDIT_GROUP'),
|
||||
CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'),
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* Used to edit a Group in the Group registry
|
||||
*/
|
||||
export class GroupRegistryEditGroupAction implements Action {
|
||||
type = GroupRegistryActionTypes.EDIT_GROUP;
|
||||
|
||||
group: Group;
|
||||
|
||||
constructor(group: Group) {
|
||||
this.group = group;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to cancel the editing of a Group in the Group registry
|
||||
*/
|
||||
export class GroupRegistryCancelGroupAction implements Action {
|
||||
type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP;
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Export a type alias of all actions in this action group
|
||||
* so that reducers can easily compose action types
|
||||
* These are all the actions to perform on the EPeople registry state
|
||||
*/
|
||||
export type GroupRegistryAction
|
||||
= GroupRegistryEditGroupAction
|
||||
| GroupRegistryCancelGroupAction
|
@@ -0,0 +1,54 @@
|
||||
import { GroupMock } from '../../../shared/testing/group-mock';
|
||||
import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction } from './group-registry.actions';
|
||||
import { groupRegistryReducer, GroupRegistryState } from './group-registry.reducers';
|
||||
|
||||
const initialState: GroupRegistryState = {
|
||||
editGroup: null,
|
||||
};
|
||||
|
||||
const editState: GroupRegistryState = {
|
||||
editGroup: GroupMock,
|
||||
};
|
||||
|
||||
class NullAction extends GroupRegistryEditGroupAction {
|
||||
type = null;
|
||||
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupRegistryReducer', () => {
|
||||
|
||||
it('should return the current state when no valid actions have been made', () => {
|
||||
const state = initialState;
|
||||
const action = new NullAction();
|
||||
const newState = groupRegistryReducer(state, action);
|
||||
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should start with an initial state', () => {
|
||||
const state = initialState;
|
||||
const action = new NullAction();
|
||||
const initState = groupRegistryReducer(undefined, action);
|
||||
|
||||
expect(initState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should update the current state to change the editGroup to a new group when GroupRegistryEditGroupAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new GroupRegistryEditGroupAction(GroupMock);
|
||||
const newState = groupRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editGroup).toEqual(GroupMock);
|
||||
});
|
||||
|
||||
it('should update the current state to remove the editGroup from the state when GroupRegistryCancelGroupAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new GroupRegistryCancelGroupAction();
|
||||
const newState = groupRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editGroup).toEqual(null);
|
||||
});
|
||||
});
|
@@ -0,0 +1,43 @@
|
||||
import { Group } from '../../../core/eperson/models/group.model';
|
||||
import { GroupRegistryAction, GroupRegistryActionTypes, GroupRegistryEditGroupAction } from './group-registry.actions';
|
||||
|
||||
/**
|
||||
* The metadata registry state.
|
||||
* @interface GroupRegistryState
|
||||
*/
|
||||
export interface GroupRegistryState {
|
||||
editGroup: Group;
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial state.
|
||||
*/
|
||||
const initialState: GroupRegistryState = {
|
||||
editGroup: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reducer that handles GroupRegistryActions to modify Groups
|
||||
* @param state The current GroupRegistryState
|
||||
* @param action The GroupRegistryAction to perform on the state
|
||||
*/
|
||||
export function groupRegistryReducer(state = initialState, action: GroupRegistryAction): GroupRegistryState {
|
||||
|
||||
switch (action.type) {
|
||||
|
||||
case GroupRegistryActionTypes.EDIT_GROUP: {
|
||||
return Object.assign({}, state, {
|
||||
editGroup: (action as GroupRegistryEditGroupAction).group
|
||||
});
|
||||
}
|
||||
|
||||
case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: {
|
||||
return Object.assign({}, state, {
|
||||
editGroup: null
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@@ -0,0 +1,84 @@
|
||||
<div class="container">
|
||||
<div class="groups-registry row">
|
||||
<div class="col-12">
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{messagePrefix + 'head' | translate}}</h2>
|
||||
|
||||
<div class="button-row top d-flex pb-2">
|
||||
<button class="mr-auto btn btn-success"
|
||||
[routerLink]="['newGroup']">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="d-none d-sm-inline"> {{messagePrefix + 'button.add' | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}
|
||||
<button (click)="clearFormAndResetResult();"
|
||||
class="btn btn-primary float-right">{{messagePrefix + 'button.see-all' | translate}}</button>
|
||||
</h3>
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-group input-group">
|
||||
<input type="text" name="query" id="query" formControlName="query"
|
||||
class="form-control" aria-label="Search input">
|
||||
<span class="input-group-append">
|
||||
<button type="submit"
|
||||
class="search-button btn btn-secondary">{{ messagePrefix + 'search.button' | translate }}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(groups | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(groups | async)?.payload"
|
||||
[collectionSize]="(groups | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
|
||||
<!-- <th scope="col">{{messagePrefix + 'table.comcol' | translate}}</th>-->
|
||||
<th>{{messagePrefix + 'table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td>{{group.name}}</td>
|
||||
<td>{{(getMembers(group) | async)?.payload?.totalElements + (getSubgroups(group) | async)?.payload?.totalElements}}</td>
|
||||
<!-- <td>{{getOptionalComColFromName(group.name)}}</td>-->
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button [routerLink]="groupService.getGroupEditPageRouterLink(group)"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: group.name} }}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button *ngIf="!group?.permanent" (click)="deleteGroup(group)"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
{{messagePrefix + 'no-items' | translate}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,139 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { Group } from '../../../core/eperson/models/group.model';
|
||||
import { RouteService } from '../../../core/services/route.service';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
import { MockRouter } from '../../../shared/mocks/mock-router';
|
||||
import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson-mock';
|
||||
import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||
import { routeServiceStub } from '../../../shared/testing/route-service-stub';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
|
||||
import { GroupsRegistryComponent } from './groups-registry.component';
|
||||
|
||||
describe('GroupRegistryComponent', () => {
|
||||
let component: GroupsRegistryComponent;
|
||||
let fixture: ComponentFixture<GroupsRegistryComponent>;
|
||||
let ePersonDataServiceStub: any;
|
||||
let groupsDataServiceStub: any;
|
||||
|
||||
let mockGroups;
|
||||
let mockEPeople;
|
||||
|
||||
beforeEach(async(() => {
|
||||
mockGroups = [GroupMock, GroupMock2];
|
||||
mockEPeople = [EPersonMock, EPersonMock2];
|
||||
ePersonDataServiceStub = {
|
||||
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||
switch (href) {
|
||||
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons':
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons':
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [EPersonMock]));
|
||||
default:
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||
}
|
||||
}
|
||||
};
|
||||
groupsDataServiceStub = {
|
||||
allGroups: mockGroups,
|
||||
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
switch (href) {
|
||||
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups':
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups':
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [GroupMock2]));
|
||||
default:
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||
}
|
||||
},
|
||||
getGroupEditPageRouterLink(group: Group): string {
|
||||
return '/admin/access-control/groups/' + group.id;
|
||||
},
|
||||
getGroupRegistryRouterLink(): string {
|
||||
return '/admin/access-control/groups';
|
||||
},
|
||||
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
if (query === '') {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allGroups));
|
||||
}
|
||||
const result = this.allGroups.find((group: Group) => {
|
||||
return (group.id.includes(query))
|
||||
});
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||
}
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [GroupsRegistryComponent],
|
||||
providers: [GroupsRegistryComponent,
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: RouteService, useValue: routeServiceStub },
|
||||
{ provide: Router, useValue: new MockRouter() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(GroupsRegistryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create GroupRegistryComponent', inject([GroupsRegistryComponent], (comp: GroupsRegistryComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
|
||||
it('should display list of groups', () => {
|
||||
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groups tr td:first-child'));
|
||||
expect(groupIdsFound.length).toEqual(2);
|
||||
mockGroups.map((group: Group) => {
|
||||
expect(groupIdsFound.find((foundEl) => {
|
||||
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||
})).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
describe('when searching with query', () => {
|
||||
let groupIdsFound;
|
||||
beforeEach(fakeAsync(() => {
|
||||
component.search({ query: GroupMock2.id });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
groupIdsFound = fixture.debugElement.queryAll(By.css('#groups tr td:first-child'));
|
||||
}));
|
||||
|
||||
it('should display search result', () => {
|
||||
expect(groupIdsFound.length).toEqual(1);
|
||||
expect(groupIdsFound.find((foundEl) => {
|
||||
return (foundEl.nativeElement.textContent.trim() === GroupMock2.uuid);
|
||||
})).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,154 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { Group } from '../../../core/eperson/models/group.model';
|
||||
import { RouteService } from '../../../core/services/route.service';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-groups-registry',
|
||||
templateUrl: './groups-registry.component.html',
|
||||
})
|
||||
/**
|
||||
* A component used for managing all existing groups within the repository.
|
||||
* The admin can create, edit or delete groups here.
|
||||
*/
|
||||
export class GroupsRegistryComponent implements OnInit {
|
||||
|
||||
messagePrefix = 'admin.access-control.groups.';
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of groups
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'groups-list-pagination',
|
||||
pageSize: 5,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* A list of all the current groups within the repository or the result of the search
|
||||
*/
|
||||
groups: Observable<RemoteData<PaginatedList<Group>>>;
|
||||
|
||||
// The search form
|
||||
searchForm;
|
||||
|
||||
// Current search in groups registry
|
||||
currentSearchQuery: string;
|
||||
|
||||
constructor(private groupService: GroupDataService,
|
||||
private ePersonDataService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private formBuilder: FormBuilder,
|
||||
protected routeService: RouteService,
|
||||
private router: Router) {
|
||||
this.currentSearchQuery = '';
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
query: this.currentSearchQuery,
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.search({ query: this.currentSearchQuery });
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ query: this.currentSearchQuery })
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in the groups (searches by group name and by uuid exact match)
|
||||
* @param data Contains query param
|
||||
*/
|
||||
search(data: any) {
|
||||
const query: string = data.query;
|
||||
if (query != null && this.currentSearchQuery !== query) {
|
||||
this.router.navigateByUrl(this.groupService.getGroupRegistryRouterLink());
|
||||
this.currentSearchQuery = query;
|
||||
this.config.currentPage = 1;
|
||||
}
|
||||
this.groups = this.groupService.searchGroups(this.currentSearchQuery.trim(), {
|
||||
currentPage: this.config.currentPage,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Group
|
||||
*/
|
||||
deleteGroup(group: Group) {
|
||||
// TODO (backend)
|
||||
console.log('TODO implement editGroup', group);
|
||||
this.notificationsService.error('TODO implement deleteGroup (not yet implemented in backend)');
|
||||
if (hasValue(group.id)) {
|
||||
this.groupService.deleteGroup(group).pipe(take(1)).subscribe((success: boolean) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.name }));
|
||||
this.forceUpdateGroup();
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + 'notification.deleted.failure', { name: group.name }));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of groups by first clearing the cache related to groups, then performing a new REST call
|
||||
*/
|
||||
public forceUpdateGroup() {
|
||||
this.groupService.clearGroupsRequests();
|
||||
this.search({ query: this.currentSearchQuery })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the members (epersons embedded value of a group)
|
||||
* @param group
|
||||
*/
|
||||
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||
return this.ePersonDataService.findAllByHref(group._links.epersons.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subgroups (groups embedded value of a group)
|
||||
* @param group
|
||||
*/
|
||||
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
|
||||
return this.groupService.findAllByHref(group._links.subgroups.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty and search all search
|
||||
*/
|
||||
clearFormAndResetResult() {
|
||||
this.searchForm.patchValue({
|
||||
query: '',
|
||||
});
|
||||
this.search({ query: '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract optional UUID from a group name => To be resolved to community or collection with link
|
||||
* (Or will be resolved in backend and added to group object, tbd) //TODO
|
||||
* @param groupName
|
||||
*/
|
||||
getOptionalComColFromName(groupName: string): string {
|
||||
return this.groupService.getUUIDFromString(groupName);
|
||||
}
|
||||
}
|
@@ -11,6 +11,7 @@ import { hasValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
|
||||
/**
|
||||
* This component renders a list of bitstream formats
|
||||
@@ -64,7 +65,7 @@ export class BitstreamFormatsComponent implements OnInit {
|
||||
const tasks$ = [];
|
||||
for (const format of formats) {
|
||||
if (hasValue(format.id)) {
|
||||
tasks$.push(this.bitstreamFormatService.delete(format.id));
|
||||
tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RestResponse) => response.isSuccessful)));
|
||||
}
|
||||
}
|
||||
zip(...tasks$).subscribe((results: boolean[]) => {
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { getAdminModulePath } from '../app-routing.module';
|
||||
import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component';
|
||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
|
||||
const REGISTRIES_MODULE_PATH = 'registries';
|
||||
const ACCESS_CONTROL_MODULE_PATH = 'access-control';
|
||||
@@ -12,6 +12,10 @@ export function getRegistriesModulePath() {
|
||||
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();
|
||||
}
|
||||
|
||||
export function getAccessControlModulePath() {
|
||||
return new URLCombiner(getAdminModulePath(), ACCESS_CONTROL_MODULE_PATH).toString();
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
@@ -28,8 +32,8 @@ export function getRegistriesModulePath() {
|
||||
resolve: { breadcrumb: I18nBreadcrumbResolver },
|
||||
component: AdminSearchPageComponent,
|
||||
data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' }
|
||||
},
|
||||
])
|
||||
}
|
||||
]),
|
||||
]
|
||||
})
|
||||
export class AdminRoutingModule {
|
||||
|
@@ -336,7 +336,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_groups',
|
||||
link: ''
|
||||
link: '/admin/access-control/groups'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
|
30
src/app/+bitstream-page/bitstream-page-routing.module.ts
Normal file
30
src/app/+bitstream-page/bitstream-page-routing.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { BitstreamPageResolver } from './bitstream-page.resolver';
|
||||
|
||||
const EDIT_BITSTREAM_PATH = ':id/edit';
|
||||
|
||||
/**
|
||||
* Routing module to help navigate Bitstream pages
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: EDIT_BITSTREAM_PATH,
|
||||
component: EditBitstreamPageComponent,
|
||||
resolve: {
|
||||
bitstream: BitstreamPageResolver
|
||||
},
|
||||
canActivate: [AuthenticatedGuard]
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
BitstreamPageResolver,
|
||||
]
|
||||
})
|
||||
export class BitstreamPageRoutingModule {
|
||||
}
|
21
src/app/+bitstream-page/bitstream-page.module.ts
Normal file
21
src/app/+bitstream-page/bitstream-page.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
|
||||
|
||||
/**
|
||||
* This module handles all components that are necessary for Bitstream related pages
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
BitstreamPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
EditBitstreamPageComponent
|
||||
]
|
||||
})
|
||||
export class BitstreamPageModule {
|
||||
}
|
31
src/app/+bitstream-page/bitstream-page.resolver.ts
Normal file
31
src/app/+bitstream-page/bitstream-page.resolver.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { find } from 'rxjs/operators';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
import { BitstreamDataService } from '../core/data/bitstream-data.service';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific bitstream before the route is activated
|
||||
*/
|
||||
@Injectable()
|
||||
export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
|
||||
constructor(private bitstreamService: BitstreamDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for resolving a bitstream based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Item>> Emits the found bitstream based on the parameters in the current route,
|
||||
* or an error if something went wrong
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Bitstream>> {
|
||||
return this.bitstreamService.findById(route.params.id)
|
||||
.pipe(
|
||||
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
<ng-container *ngVar="(bitstreamRD$ | async) as bitstreamRD">
|
||||
<div class="container" *ngVar="(bitstreamFormatsRD$ | async) as formatsRD">
|
||||
<div class="row" *ngIf="bitstreamRD?.hasSucceeded && formatsRD?.hasSucceeded">
|
||||
<div class="col-md-2">
|
||||
<ds-thumbnail [thumbnail]="bitstreamRD?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3>{{bitstreamRD?.payload?.name}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-form [formId]="'edit-bitstream-form-id'"
|
||||
[formGroup]="formGroup"
|
||||
[formModel]="formModel"
|
||||
[formLayout]="formLayout"
|
||||
[submitLabel]="'form.save'"
|
||||
(submitForm)="onSubmit()"
|
||||
(cancel)="onCancel()"
|
||||
(dfChange)="onChange($event)"></ds-form>
|
||||
</div>
|
||||
</div>
|
||||
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
|
||||
message="{{'loading.bitstream' | translate}}"></ds-loading>
|
||||
</div>
|
||||
</ng-container>
|
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
::ng-deep {
|
||||
.switch {
|
||||
position: absolute;
|
||||
top: $spacer*2.5;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,216 @@
|
||||
import { EditBitstreamPageComponent } from './edit-bitstream-page.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { NotificationType } from '../../shared/notifications/models/notification-type';
|
||||
import { INotification, Notification } from '../../shared/notifications/models/notification.model';
|
||||
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { PageInfo } from '../../core/shared/page-info.model';
|
||||
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
|
||||
import { RestResponse } from '../../core/cache/response.models';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
|
||||
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||
|
||||
let notificationsService: NotificationsService;
|
||||
let formService: DynamicFormService;
|
||||
let bitstreamService: BitstreamDataService;
|
||||
let bitstreamFormatService: BitstreamFormatDataService;
|
||||
let bitstream: Bitstream;
|
||||
let selectedFormat: BitstreamFormat;
|
||||
let allFormats: BitstreamFormat[];
|
||||
|
||||
describe('EditBitstreamPageComponent', () => {
|
||||
let comp: EditBitstreamPageComponent;
|
||||
let fixture: ComponentFixture<EditBitstreamPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
allFormats = [
|
||||
Object.assign({
|
||||
id: '1',
|
||||
shortDescription: 'Unknown',
|
||||
description: 'Unknown format',
|
||||
supportLevel: BitstreamFormatSupportLevel.Unknown,
|
||||
_links: {
|
||||
self: { href: 'format-selflink-1' }
|
||||
}
|
||||
}),
|
||||
Object.assign({
|
||||
id: '2',
|
||||
shortDescription: 'PNG',
|
||||
description: 'Portable Network Graphics',
|
||||
supportLevel: BitstreamFormatSupportLevel.Known,
|
||||
_links: {
|
||||
self: { href: 'format-selflink-2' }
|
||||
}
|
||||
}),
|
||||
Object.assign({
|
||||
id: '3',
|
||||
shortDescription: 'GIF',
|
||||
description: 'Graphics Interchange Format',
|
||||
supportLevel: BitstreamFormatSupportLevel.Known,
|
||||
_links: {
|
||||
self: { href: 'format-selflink-3' }
|
||||
}
|
||||
})
|
||||
] as BitstreamFormat[];
|
||||
selectedFormat = allFormats[1];
|
||||
notificationsService = jasmine.createSpyObj('notificationsService',
|
||||
{
|
||||
info: infoNotification,
|
||||
warning: warningNotification,
|
||||
success: successNotification
|
||||
}
|
||||
);
|
||||
formService = Object.assign({
|
||||
createFormGroup: (fModel: DynamicFormControlModel[]) => {
|
||||
const controls = {};
|
||||
if (hasValue(fModel)) {
|
||||
fModel.forEach((controlModel) => {
|
||||
controls[controlModel.id] = new FormControl((controlModel as any).value);
|
||||
});
|
||||
return new FormGroup(controls);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
bitstream = Object.assign(new Bitstream(), {
|
||||
metadata: {
|
||||
'dc.description': [
|
||||
{
|
||||
value: 'Bitstream description'
|
||||
}
|
||||
],
|
||||
'dc.title': [
|
||||
{
|
||||
value: 'Bitstream title'
|
||||
}
|
||||
]
|
||||
},
|
||||
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
|
||||
_links: {
|
||||
self: 'bitstream-selflink'
|
||||
}
|
||||
});
|
||||
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||
findById: observableOf(new RemoteData(false, false, true, null, bitstream)),
|
||||
update: observableOf(new RemoteData(false, false, true, null, bitstream)),
|
||||
updateFormat: observableOf(new RestResponse(true, 200, 'OK')),
|
||||
commitUpdates: {},
|
||||
patch: {}
|
||||
});
|
||||
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
|
||||
findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats)))
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
|
||||
providers: [
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: DynamicFormService, useValue: formService },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } },
|
||||
{ provide: BitstreamDataService, useValue: bitstreamService },
|
||||
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
|
||||
ChangeDetectorRef
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditBitstreamPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('on startup', () => {
|
||||
let rawForm;
|
||||
|
||||
beforeEach(() => {
|
||||
rawForm = comp.formGroup.getRawValue();
|
||||
});
|
||||
|
||||
it('should fill in the bitstream\'s title', () => {
|
||||
expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name);
|
||||
});
|
||||
|
||||
it('should fill in the bitstream\'s description', () => {
|
||||
expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description'));
|
||||
});
|
||||
|
||||
it('should select the correct format', () => {
|
||||
expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id);
|
||||
});
|
||||
|
||||
it('should put the \"New Format\" input on invisible', () => {
|
||||
expect(comp.formLayout.newFormat.grid.host).toContain('invisible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an unknown format is selected', () => {
|
||||
beforeEach(() => {
|
||||
comp.updateNewFormatLayout(allFormats[0].id);
|
||||
});
|
||||
|
||||
it('should remove the invisible class from the \"New Format\" input', () => {
|
||||
expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSubmit', () => {
|
||||
describe('when selected format hasn\'t changed', () => {
|
||||
beforeEach(() => {
|
||||
comp.onSubmit();
|
||||
});
|
||||
|
||||
it('should call update', () => {
|
||||
expect(bitstreamService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should commit the updates', () => {
|
||||
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when selected format has changed', () => {
|
||||
beforeEach(() => {
|
||||
comp.formGroup.patchValue({
|
||||
formatContainer: {
|
||||
selectedFormat: allFormats[2].id
|
||||
}
|
||||
});
|
||||
fixture.detectChanges();
|
||||
comp.onSubmit();
|
||||
});
|
||||
|
||||
it('should call update', () => {
|
||||
expect(bitstreamService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call updateFormat', () => {
|
||||
expect(bitstreamService.updateFormat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should commit the updates', () => {
|
||||
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,524 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { filter, map, switchMap } from 'rxjs/operators';
|
||||
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormGroupModel,
|
||||
DynamicFormLayout,
|
||||
DynamicFormService,
|
||||
DynamicInputModel,
|
||||
DynamicSelectModel,
|
||||
DynamicTextAreaModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
import {
|
||||
getAllSucceededRemoteData, getAllSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload,
|
||||
getSucceededRemoteData
|
||||
} from '../../core/shared/operators';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
|
||||
import { RestResponse } from '../../core/cache/response.models';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { Metadata } from '../../core/shared/metadata.utils';
|
||||
import { Location } from '@angular/common';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { getItemEditPath } from '../../+item-page/item-page-routing.module';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-bitstream-page',
|
||||
styleUrls: ['./edit-bitstream-page.component.scss'],
|
||||
templateUrl: './edit-bitstream-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
/**
|
||||
* Page component for editing a bitstream
|
||||
*/
|
||||
export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* The bitstream's remote data observable
|
||||
* Tracks changes and updates the view
|
||||
*/
|
||||
bitstreamRD$: Observable<RemoteData<Bitstream>>;
|
||||
|
||||
/**
|
||||
* The formats their remote data observable
|
||||
* Tracks changes and updates the view
|
||||
*/
|
||||
bitstreamFormatsRD$: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
|
||||
|
||||
/**
|
||||
* The bitstream to edit
|
||||
*/
|
||||
bitstream: Bitstream;
|
||||
|
||||
/**
|
||||
* The originally selected format
|
||||
*/
|
||||
originalFormat: BitstreamFormat;
|
||||
|
||||
/**
|
||||
* A list of all available bitstream formats
|
||||
*/
|
||||
formats: BitstreamFormat[];
|
||||
|
||||
/**
|
||||
* @type {string} Key prefix used to generate form messages
|
||||
*/
|
||||
KEY_PREFIX = 'bitstream.edit.form.';
|
||||
|
||||
/**
|
||||
* @type {string} Key suffix used to generate form labels
|
||||
*/
|
||||
LABEL_KEY_SUFFIX = '.label';
|
||||
|
||||
/**
|
||||
* @type {string} Key suffix used to generate form labels
|
||||
*/
|
||||
HINT_KEY_SUFFIX = '.hint';
|
||||
|
||||
/**
|
||||
* @type {string} Key prefix used to generate notification messages
|
||||
*/
|
||||
NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.';
|
||||
|
||||
/**
|
||||
* Options for fetching all bitstream formats
|
||||
*/
|
||||
findAllOptions = { elementsPerPage: 9999 };
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for the file's name
|
||||
*/
|
||||
fileNameModel = new DynamicInputModel({
|
||||
id: 'fileName',
|
||||
name: 'fileName',
|
||||
required: true,
|
||||
validators: {
|
||||
required: null
|
||||
},
|
||||
errorMessages: {
|
||||
required: 'You must provide a file name for the bitstream'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Switch Model for the file's name
|
||||
*/
|
||||
primaryBitstreamModel = new DynamicCustomSwitchModel({
|
||||
id: 'primaryBitstream',
|
||||
name: 'primaryBitstream'
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic TextArea Model for the file's description
|
||||
*/
|
||||
descriptionModel = new DynamicTextAreaModel({
|
||||
id: 'description',
|
||||
name: 'description',
|
||||
rows: 10
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for the file's embargo (disabled on this page)
|
||||
*/
|
||||
embargoModel = new DynamicInputModel({
|
||||
id: 'embargo',
|
||||
name: 'embargo',
|
||||
disabled: true
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for the selected format
|
||||
*/
|
||||
selectedFormatModel = new DynamicSelectModel({
|
||||
id: 'selectedFormat',
|
||||
name: 'selectedFormat'
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for supplying more format information
|
||||
*/
|
||||
newFormatModel = new DynamicInputModel({
|
||||
id: 'newFormat',
|
||||
name: 'newFormat'
|
||||
});
|
||||
|
||||
/**
|
||||
* All input models in a simple array for easier iterations
|
||||
*/
|
||||
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel];
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for editing the information of a bitstream
|
||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||
*/
|
||||
formModel: DynamicFormControlModel[] = [
|
||||
new DynamicFormGroupModel({
|
||||
id: 'fileNamePrimaryContainer',
|
||||
group: [
|
||||
this.fileNameModel,
|
||||
this.primaryBitstreamModel
|
||||
]
|
||||
}),
|
||||
new DynamicFormGroupModel({
|
||||
id: 'descriptionContainer',
|
||||
group: [
|
||||
this.descriptionModel
|
||||
]
|
||||
}),
|
||||
new DynamicFormGroupModel({
|
||||
id: 'embargoContainer',
|
||||
group: [
|
||||
this.embargoModel
|
||||
]
|
||||
}),
|
||||
new DynamicFormGroupModel({
|
||||
id: 'formatContainer',
|
||||
group: [
|
||||
this.selectedFormatModel,
|
||||
this.newFormatModel
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
/**
|
||||
* The base layout of the "Other Format" input
|
||||
*/
|
||||
newFormatBaseLayout = 'col col-sm-6 d-inline-block';
|
||||
|
||||
/**
|
||||
* Layout used for structuring the form inputs
|
||||
*/
|
||||
formLayout: DynamicFormLayout = {
|
||||
fileName: {
|
||||
grid: {
|
||||
host: 'col col-sm-8 d-inline-block'
|
||||
}
|
||||
},
|
||||
primaryBitstream: {
|
||||
grid: {
|
||||
host: 'col col-sm-4 d-inline-block switch'
|
||||
}
|
||||
},
|
||||
description: {
|
||||
grid: {
|
||||
host: 'col-12 d-inline-block'
|
||||
}
|
||||
},
|
||||
embargo: {
|
||||
grid: {
|
||||
host: 'col-12 d-inline-block'
|
||||
}
|
||||
},
|
||||
selectedFormat: {
|
||||
grid: {
|
||||
host: 'col col-sm-6 d-inline-block'
|
||||
}
|
||||
},
|
||||
newFormat: {
|
||||
grid: {
|
||||
host: this.newFormatBaseLayout + ' invisible'
|
||||
}
|
||||
},
|
||||
fileNamePrimaryContainer: {
|
||||
grid: {
|
||||
host: 'row position-relative'
|
||||
}
|
||||
},
|
||||
descriptionContainer: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
embargoContainer: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
formatContainer: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The form group of this form
|
||||
*/
|
||||
formGroup: FormGroup;
|
||||
|
||||
/**
|
||||
* The ID of the item the bitstream originates from
|
||||
* Taken from the current query parameters when present
|
||||
*/
|
||||
itemId: string;
|
||||
|
||||
/**
|
||||
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||
* @type {Array}
|
||||
*/
|
||||
protected subs: Subscription[] = [];
|
||||
|
||||
constructor(private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private formService: DynamicFormService,
|
||||
private translate: TranslateService,
|
||||
private bitstreamService: BitstreamDataService,
|
||||
private notificationsService: NotificationsService,
|
||||
private bitstreamFormatService: BitstreamFormatDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component
|
||||
* - Create a FormGroup using the FormModel defined earlier
|
||||
* - Subscribe on the route data to fetch the bitstream to edit and update the form values
|
||||
* - Translate the form labels and hints
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.formGroup = this.formService.createFormGroup(this.formModel);
|
||||
|
||||
this.itemId = this.route.snapshot.queryParams.itemId;
|
||||
this.bitstreamRD$ = this.route.data.pipe(map((data) => data.bitstream));
|
||||
this.bitstreamFormatsRD$ = this.bitstreamFormatService.findAll(this.findAllOptions);
|
||||
|
||||
const bitstream$ = this.bitstreamRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
|
||||
getAllSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
filter((bs: Bitstream) => hasValue(bs)))
|
||||
)
|
||||
);
|
||||
|
||||
const allFormats$ = this.bitstreamFormatsRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload()
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
bitstream$,
|
||||
allFormats$
|
||||
).subscribe(([bitstream, allFormats]) => {
|
||||
this.bitstream = bitstream as Bitstream;
|
||||
this.formats = allFormats.page;
|
||||
this.updateFormatModel();
|
||||
this.updateForm(this.bitstream);
|
||||
})
|
||||
);
|
||||
|
||||
this.updateFieldTranslations();
|
||||
|
||||
this.subs.push(
|
||||
this.translate.onLangChange
|
||||
.subscribe(() => {
|
||||
this.updateFieldTranslations();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the current form values with bitstream properties
|
||||
* @param bitstream
|
||||
*/
|
||||
updateForm(bitstream: Bitstream) {
|
||||
this.formGroup.patchValue({
|
||||
fileNamePrimaryContainer: {
|
||||
fileName: bitstream.name,
|
||||
primaryBitstream: false
|
||||
},
|
||||
descriptionContainer: {
|
||||
description: bitstream.firstMetadataValue('dc.description')
|
||||
},
|
||||
formatContainer: {
|
||||
newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined
|
||||
}
|
||||
});
|
||||
this.bitstream.format.pipe(
|
||||
getAllSucceededRemoteDataPayload()
|
||||
).subscribe((format: BitstreamFormat) => {
|
||||
this.originalFormat = format;
|
||||
this.formGroup.patchValue({
|
||||
formatContainer: {
|
||||
selectedFormat: format.id
|
||||
}
|
||||
});
|
||||
this.updateNewFormatLayout(format.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the list of unknown format IDs an add options to the selectedFormatModel
|
||||
*/
|
||||
updateFormatModel() {
|
||||
this.selectedFormatModel.options = this.formats.map((format: BitstreamFormat) =>
|
||||
Object.assign({
|
||||
value: format.id,
|
||||
label: this.isUnknownFormat(format.id) ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the layout of the "Other Format" input depending on the selected format
|
||||
* @param selectedId
|
||||
*/
|
||||
updateNewFormatLayout(selectedId: string) {
|
||||
if (this.isUnknownFormat(selectedId)) {
|
||||
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout;
|
||||
} else {
|
||||
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout + ' invisible';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the provided format (id) part of the list of unknown formats?
|
||||
* @param id
|
||||
*/
|
||||
isUnknownFormat(id: string): boolean {
|
||||
const format = this.formats.find((f: BitstreamFormat) => f.id === id);
|
||||
return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to update translations of labels and hints on init and on language change
|
||||
*/
|
||||
private updateFieldTranslations() {
|
||||
this.inputModels.forEach(
|
||||
(fieldModel: DynamicFormControlModel) => {
|
||||
this.updateFieldTranslation(fieldModel);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the translations of a DynamicFormControlModel
|
||||
* @param fieldModel
|
||||
*/
|
||||
private updateFieldTranslation(fieldModel) {
|
||||
fieldModel.label = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.LABEL_KEY_SUFFIX);
|
||||
if (fieldModel.id !== this.primaryBitstreamModel.id) {
|
||||
fieldModel.hint = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.HINT_KEY_SUFFIX);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired whenever the form receives an update and changes the layout of the "Other Format" input, depending on the selected format
|
||||
* @param event
|
||||
*/
|
||||
onChange(event) {
|
||||
const model = event.model;
|
||||
if (model.id === this.selectedFormatModel.id) {
|
||||
this.updateNewFormatLayout(model.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for changes against the bitstream and send update requests to the REST API
|
||||
*/
|
||||
onSubmit() {
|
||||
const updatedValues = this.formGroup.getRawValue();
|
||||
const updatedBitstream = this.formToBitstream(updatedValues);
|
||||
const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat);
|
||||
const isNewFormat = selectedFormat.id !== this.originalFormat.id;
|
||||
|
||||
let bitstream$;
|
||||
|
||||
if (isNewFormat) {
|
||||
bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe(
|
||||
switchMap((formatResponse: RestResponse) => {
|
||||
if (hasValue(formatResponse) && !formatResponse.isSuccessful) {
|
||||
this.notificationsService.error(
|
||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.format.title'),
|
||||
formatResponse.statusText
|
||||
);
|
||||
} else {
|
||||
return this.bitstreamService.findById(this.bitstream.id).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
bitstream$ = observableOf(this.bitstream);
|
||||
}
|
||||
|
||||
bitstream$.pipe(
|
||||
switchMap(() => {
|
||||
return this.bitstreamService.update(updatedBitstream).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
);
|
||||
})
|
||||
).subscribe(() => {
|
||||
this.bitstreamService.commitUpdates();
|
||||
this.notificationsService.success(
|
||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.title'),
|
||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.content')
|
||||
);
|
||||
this.navigateToItemEditBitstreams();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse form data to an updated bitstream object
|
||||
* @param rawForm Raw form data
|
||||
*/
|
||||
formToBitstream(rawForm): Bitstream {
|
||||
const updatedBitstream = cloneDeep(this.bitstream);
|
||||
const newMetadata = updatedBitstream.metadata;
|
||||
// TODO: Set bitstream to primary when supported
|
||||
const primary = rawForm.fileNamePrimaryContainer.primaryBitstream;
|
||||
Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName);
|
||||
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description);
|
||||
if (isNotEmpty(rawForm.formatContainer.newFormat)) {
|
||||
Metadata.setFirstValue(newMetadata, 'dc.format', rawForm.formatContainer.newFormat);
|
||||
}
|
||||
updatedBitstream.metadata = newMetadata;
|
||||
return updatedBitstream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the form and return to the previous page
|
||||
*/
|
||||
onCancel() {
|
||||
this.navigateToItemEditBitstreams();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous
|
||||
* page the user came from
|
||||
*/
|
||||
navigateToItemEditBitstreams() {
|
||||
if (hasValue(this.itemId)) {
|
||||
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
|
||||
} else {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from open subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs
|
||||
.filter((subscription) => hasValue(subscription))
|
||||
.forEach((subscription) => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
@@ -34,6 +34,11 @@ const COLLECTION_EDIT_PATH = 'edit';
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: COLLECTION_CREATE_PATH,
|
||||
component: CreateCollectionPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
resolve: {
|
||||
@@ -66,11 +71,6 @@ const COLLECTION_EDIT_PATH = 'edit';
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: COLLECTION_CREATE_PATH,
|
||||
component: CreateCollectionPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
|
||||
},
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
|
@@ -0,0 +1,6 @@
|
||||
<ds-comcol-role
|
||||
*ngFor="let comcolRole of getComcolRoles() | async"
|
||||
[dso]="collection$ | async"
|
||||
[comcolRole]="comcolRole"
|
||||
>
|
||||
</ds-comcol-role>
|
||||
|
@@ -0,0 +1,121 @@
|
||||
import { ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { CollectionRolesComponent } from './collection-roles.component';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
describe('CollectionRolesComponent', () => {
|
||||
|
||||
let fixture: ComponentFixture<CollectionRolesComponent>;
|
||||
let comp: CollectionRolesComponent;
|
||||
let de: DebugElement;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
const route = {
|
||||
parent: {
|
||||
data: observableOf({
|
||||
dso: new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
Object.assign(new Collection(), {
|
||||
_links: {
|
||||
'irrelevant': {
|
||||
href: 'irrelevant link',
|
||||
},
|
||||
'adminGroup': {
|
||||
href: 'adminGroup link',
|
||||
},
|
||||
'submittersGroup': {
|
||||
href: 'submittersGroup link',
|
||||
},
|
||||
'itemReadGroup': {
|
||||
href: 'itemReadGroup link',
|
||||
},
|
||||
'bitstreamReadGroup': {
|
||||
href: 'bitstreamReadGroup link',
|
||||
},
|
||||
'workflowGroups/test': {
|
||||
href: 'test workflow group link',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const requestService = {
|
||||
hasByHrefObservable: () => observableOf(true),
|
||||
};
|
||||
|
||||
const groupDataService = {
|
||||
findByHref: () => observableOf(new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
{},
|
||||
200,
|
||||
)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
RouterTestingModule.withRoutes([]),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
CollectionRolesComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: GroupDataService, useValue: groupDataService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CollectionRolesComponent);
|
||||
comp = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display a collection admin role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .collection-admin')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display a submitters role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .submitters')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display a default item read role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .item_read')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display a default bitstream read role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .bitstream_read')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display a test workflow role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .test')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -1,4 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role';
|
||||
|
||||
/**
|
||||
* Component for managing a collection's roles
|
||||
@@ -7,6 +14,48 @@ import { Component } from '@angular/core';
|
||||
selector: 'ds-collection-roles',
|
||||
templateUrl: './collection-roles.component.html',
|
||||
})
|
||||
export class CollectionRolesComponent {
|
||||
/* TODO: Implement Collection Edit - Roles */
|
||||
export class CollectionRolesComponent implements OnInit {
|
||||
|
||||
dsoRD$: Observable<RemoteData<Collection>>;
|
||||
|
||||
/**
|
||||
* The collection to manage, as an observable.
|
||||
*/
|
||||
get collection$(): Observable<Collection> {
|
||||
return this.dsoRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The different roles for the collection, as an observable.
|
||||
*/
|
||||
getComcolRoles(): Observable<ComcolRole[]> {
|
||||
return this.collection$.pipe(
|
||||
map((collection) =>
|
||||
[
|
||||
ComcolRole.COLLECTION_ADMIN,
|
||||
ComcolRole.SUBMITTERS,
|
||||
ComcolRole.ITEM_READ,
|
||||
ComcolRole.BITSTREAM_READ,
|
||||
...Object.keys(collection._links)
|
||||
.filter((link) => link.startsWith('workflowGroups/'))
|
||||
.map((link) => new ComcolRole(link.substr('workflowGroups/'.length), link)),
|
||||
]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dsoRD$ = this.route.parent.data.pipe(
|
||||
first(),
|
||||
map((data) => data.dso),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -33,6 +33,11 @@ const COMMUNITY_EDIT_PATH = 'edit';
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: COMMUNITY_CREATE_PATH,
|
||||
component: CreateCommunityPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
resolve: {
|
||||
@@ -59,11 +64,6 @@ const COMMUNITY_EDIT_PATH = 'edit';
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: COMMUNITY_CREATE_PATH,
|
||||
component: CreateCommunityPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
|
||||
},
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
|
@@ -0,0 +1,6 @@
|
||||
<ds-comcol-role
|
||||
*ngFor="let comcolRole of getComcolRoles()"
|
||||
[dso]="community$ | async"
|
||||
[comcolRole]="comcolRole"
|
||||
>
|
||||
</ds-comcol-role>
|
||||
|
@@ -0,0 +1,89 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { CommunityRolesComponent } from './community-roles.component';
|
||||
import { Community } from '../../../core/shared/community.model';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
describe('CommunityRolesComponent', () => {
|
||||
|
||||
let fixture: ComponentFixture<CommunityRolesComponent>;
|
||||
let comp: CommunityRolesComponent;
|
||||
let de: DebugElement;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
const route = {
|
||||
parent: {
|
||||
data: observableOf({
|
||||
dso: new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
Object.assign(new Community(), {
|
||||
_links: {
|
||||
irrelevant: {
|
||||
href: 'irrelevant link',
|
||||
},
|
||||
adminGroup: {
|
||||
href: 'adminGroup link',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const requestService = {
|
||||
hasByHrefObservable: () => observableOf(true),
|
||||
};
|
||||
|
||||
const groupDataService = {
|
||||
findByHref: () => observableOf(new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
{},
|
||||
200,
|
||||
)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
RouterTestingModule.withRoutes([]),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
CommunityRolesComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: GroupDataService, useValue: groupDataService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CommunityRolesComponent);
|
||||
comp = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display a community admin role component', () => {
|
||||
expect(de.query(By.css('ds-comcol-role .community-admin')))
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -1,4 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { Community } from '../../../core/shared/community.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
|
||||
/**
|
||||
* Component for managing a community's roles
|
||||
@@ -7,6 +14,38 @@ import { Component } from '@angular/core';
|
||||
selector: 'ds-community-roles',
|
||||
templateUrl: './community-roles.component.html',
|
||||
})
|
||||
export class CommunityRolesComponent {
|
||||
/* TODO: Implement Community Edit - Roles */
|
||||
export class CommunityRolesComponent implements OnInit {
|
||||
|
||||
dsoRD$: Observable<RemoteData<Community>>;
|
||||
|
||||
/**
|
||||
* The community to manage, as an observable.
|
||||
*/
|
||||
get community$(): Observable<Community> {
|
||||
return this.dsoRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The different roles for the community.
|
||||
*/
|
||||
getComcolRoles(): ComcolRole[] {
|
||||
return [
|
||||
ComcolRole.COMMUNITY_ADMIN,
|
||||
];
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dsoRD$ = this.route.parent.data.pipe(
|
||||
first(),
|
||||
map((data) => data.dso),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,41 @@
|
||||
<div class="container" *ngVar="(bundlesRD$ | async)?.payload?.page as bundles">
|
||||
<ng-container *ngIf="bundles">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<h2>{{'item.bitstreams.upload.title' | translate}}</h2>
|
||||
<ng-container *ngVar="(itemRD$ | async)?.payload as item">
|
||||
<div *ngIf="item">
|
||||
<span class="font-weight-bold">{{'item.bitstreams.upload.item' | translate}}</span>
|
||||
<span>{{item.name}}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="font-weight-bold">{{'item.bitstreams.upload.bundle' | translate}}</label>
|
||||
<ds-dso-input-suggestions #f id="search-form"
|
||||
[suggestions]="bundles"
|
||||
[placeholder]="'item.bitstreams.upload.bundle.placeholder' | translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="'bundle-select'"
|
||||
[debounceTime]="50"
|
||||
[(ngModel)]="selectedBundleName"
|
||||
(typeSuggestion)="bundleNameChange()"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(click)="f.open()"
|
||||
ngDefaultControl>
|
||||
</ds-dso-input-suggestions>
|
||||
<button *ngIf="!selectedBundleId && selectedBundleName?.length > 0" class="btn btn-success" (click)="createBundle()">
|
||||
<i class="fa fa-plus"></i> {{ 'item.bitstreams.upload.bundle.new' | translate }}
|
||||
</button>
|
||||
<ds-uploader class="w-100" *ngIf="selectedBundleId"
|
||||
[dropMsg]="'item.bitstreams.upload.drop-message'"
|
||||
[dropOverDocumentMsg]="'item.bitstreams.upload.drop-message'"
|
||||
[enableDragOverDocument]="true"
|
||||
[uploadFilesOptions]="uploadFilesOptions"
|
||||
(onCompleteItem)="onCompleteItem($event)"
|
||||
(onUploadError)="onUploadError()"></ds-uploader>
|
||||
<button class="btn btn-outline-secondary" (click)="onCancel()">{{'item.bitstreams.upload.cancel' | translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
@@ -0,0 +1,236 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { UploadBitstreamComponent } from './upload-bitstream.component';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { AuthServiceStub } from '../../../shared/testing/auth-service-stub';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import {
|
||||
createPaginatedList,
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../../shared/testing/utils';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||
import { Bundle } from '../../../core/shared/bundle.model';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
|
||||
describe('UploadBistreamComponent', () => {
|
||||
let comp: UploadBitstreamComponent;
|
||||
let fixture: ComponentFixture<UploadBitstreamComponent>;
|
||||
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
id: 'bundle',
|
||||
uuid: 'bundle',
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
value: 'bundleName',
|
||||
language: null
|
||||
}
|
||||
]
|
||||
},
|
||||
_links: {
|
||||
self: { href: 'bundle-selflink' }
|
||||
}
|
||||
});
|
||||
const customName = 'Custom Name';
|
||||
const createdBundle = Object.assign(new Bundle(), {
|
||||
id: 'created-bundle',
|
||||
uuid: 'created-bundle',
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
value: customName,
|
||||
language: null
|
||||
}
|
||||
]
|
||||
},
|
||||
_links: {
|
||||
self: { href: 'created-bundle-selflink' }
|
||||
}
|
||||
});
|
||||
const itemName = 'fake-name';
|
||||
const mockItem = Object.assign(new Item(), {
|
||||
id: 'fake-id',
|
||||
handle: 'fake/handle',
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
language: null,
|
||||
value: itemName
|
||||
}
|
||||
]
|
||||
},
|
||||
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([bundle]))
|
||||
});
|
||||
let routeStub;
|
||||
const routerStub = new RouterStub();
|
||||
const restEndpoint = 'fake-rest-endpoint';
|
||||
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
|
||||
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||
createBundle: createSuccessfulRemoteDataObject$(createdBundle)
|
||||
});
|
||||
const bundleService = jasmine.createSpyObj('bundleService', {
|
||||
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||
findById: createSuccessfulRemoteDataObject$(bundle)
|
||||
});
|
||||
const authToken = 'fake-auth-token';
|
||||
const authServiceStub = Object.assign(new AuthServiceStub(), {
|
||||
buildAuthHeader: () => authToken
|
||||
});
|
||||
const notificationsServiceStub = new NotificationsServiceStub();
|
||||
const uploaderComponent = jasmine.createSpyObj('uploaderComponent', ['ngOnInit', 'ngAfterViewInit']);
|
||||
const requestService = jasmine.createSpyObj('requestService', {
|
||||
removeByHrefSubstring: {}
|
||||
});
|
||||
|
||||
describe('when a file is uploaded', () => {
|
||||
beforeEach(async(() => {
|
||||
createUploadBitstreamTestingModule({});
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtureAndComp();
|
||||
});
|
||||
|
||||
describe('and it fails, calling onUploadError', () => {
|
||||
beforeEach(() => {
|
||||
comp.onUploadError();
|
||||
});
|
||||
|
||||
it('should display an error notification', () => {
|
||||
expect(notificationsServiceStub.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and it succeeds, calling onCompleteItem', () => {
|
||||
const createdBitstream = Object.assign(new Bitstream(), {
|
||||
id: 'fake-bitstream'
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
comp.onCompleteItem(createdBitstream);
|
||||
});
|
||||
|
||||
it('should navigate the user to the next page', () => {
|
||||
expect(routerStub.navigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a bundle url parameter is present', () => {
|
||||
beforeEach(async(() => {
|
||||
createUploadBitstreamTestingModule({
|
||||
bundle: bundle.id
|
||||
});
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtureAndComp();
|
||||
});
|
||||
|
||||
it('should set the selected id to the bundle\'s id', () => {
|
||||
expect(comp.selectedBundleId).toEqual(bundle.id);
|
||||
});
|
||||
|
||||
it('should set the selected name to the bundle\'s name', () => {
|
||||
expect(comp.selectedBundleName).toEqual(bundle.name);
|
||||
});
|
||||
|
||||
describe('and bundle name changed', () => {
|
||||
beforeEach(() => {
|
||||
comp.bundleNameChange();
|
||||
});
|
||||
|
||||
it('should clear out the selected id', () => {
|
||||
expect(comp.selectedBundleId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a name is filled in, but no ID is selected', () => {
|
||||
beforeEach(async(() => {
|
||||
createUploadBitstreamTestingModule({});
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtureAndComp();
|
||||
comp.selectedBundleName = customName;
|
||||
});
|
||||
|
||||
describe('createBundle', () => {
|
||||
beforeEach(() => {
|
||||
comp.createBundle();
|
||||
});
|
||||
|
||||
it('should create a new bundle', () => {
|
||||
expect(mockItemDataService.createBundle).toHaveBeenCalledWith(mockItem.id, customName);
|
||||
});
|
||||
|
||||
it('should set the selected id to the id of the new bundle', () => {
|
||||
expect(comp.selectedBundleId).toEqual(createdBundle.id);
|
||||
});
|
||||
|
||||
it('should display a success notification', () => {
|
||||
expect(notificationsServiceStub.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Setup an UploadBitstreamComponent testing module with custom queryParams for the route
|
||||
* @param queryParams
|
||||
*/
|
||||
function createUploadBitstreamTestingModule(queryParams) {
|
||||
routeStub = {
|
||||
data: observableOf({
|
||||
item: createSuccessfulRemoteDataObject(mockItem)
|
||||
}),
|
||||
queryParams: observableOf(queryParams),
|
||||
snapshot: {
|
||||
queryParams: queryParams,
|
||||
params: {
|
||||
id: mockItem.id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||
declarations: [UploadBitstreamComponent, VarDirective],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
{ provide: ItemDataService, useValue: mockItemDataService },
|
||||
{ provide: NotificationsService, useValue: notificationsServiceStub },
|
||||
{ provide: AuthService, useValue: authServiceStub },
|
||||
{ provide: BundleDataService, useValue: bundleService },
|
||||
{ provide: RequestService, useValue: requestService }
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the TestBed's fixture and component
|
||||
*/
|
||||
function loadFixtureAndComp() {
|
||||
fixture = TestBed.createComponent(UploadBitstreamComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.uploaderComponent = uploaderComponent;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
});
|
@@ -0,0 +1,218 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { getBitstreamModulePath } from '../../../app-routing.module';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { Bundle } from '../../../core/shared/bundle.model';
|
||||
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||
import {
|
||||
getFirstSucceededRemoteDataPayload
|
||||
} from '../../../core/shared/operators';
|
||||
import { UploaderComponent } from '../../../shared/uploader/uploader.component';
|
||||
import { getItemEditPath } from '../../item-page-routing.module';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-upload-bitstream',
|
||||
templateUrl: './upload-bitstream.component.html'
|
||||
})
|
||||
/**
|
||||
* Page component for uploading a bitstream to an item
|
||||
*/
|
||||
export class UploadBitstreamComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The file uploader component
|
||||
*/
|
||||
@ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent;
|
||||
|
||||
/**
|
||||
* The ID of the item to upload a bitstream to
|
||||
*/
|
||||
itemId: string;
|
||||
|
||||
/**
|
||||
* The item to upload a bitstream to
|
||||
*/
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
/**
|
||||
* The item's bundles
|
||||
*/
|
||||
bundlesRD$: Observable<RemoteData<PaginatedList<Bundle>>>;
|
||||
|
||||
/**
|
||||
* The ID of the currently selected bundle to upload a bitstream to
|
||||
*/
|
||||
selectedBundleId: string;
|
||||
|
||||
/**
|
||||
* The name of the currently selected bundle to upload a bitstream to
|
||||
*/
|
||||
selectedBundleName: string;
|
||||
|
||||
/**
|
||||
* The uploader configuration options
|
||||
* @type {UploaderOptions}
|
||||
*/
|
||||
uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), {
|
||||
// URL needs to contain something to not produce any errors. This will be replaced once a bundle has been selected.
|
||||
url: 'placeholder',
|
||||
authToken: null,
|
||||
disableMultipart: false,
|
||||
itemAlias: null
|
||||
});
|
||||
|
||||
/**
|
||||
* The prefix for all i18n notification messages within this component
|
||||
*/
|
||||
NOTIFICATIONS_PREFIX = 'item.bitstreams.upload.notifications.';
|
||||
|
||||
/**
|
||||
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||
* @type {Array}
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected itemService: ItemDataService,
|
||||
protected bundleService: BundleDataService,
|
||||
protected authService: AuthService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translate: TranslateService,
|
||||
protected requestService: RequestService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize component properties:
|
||||
* itemRD$ Fetched from the current route data (populated by BitstreamPageResolver)
|
||||
* bundlesRD$ List of bundles on the item
|
||||
* selectedBundleId Starts off by checking if the route's queryParams contain a "bundle" parameter. If none is found,
|
||||
* the ID of the first bundle in the list is selected.
|
||||
* Calls setUploadUrl after setting the selected bundle
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.itemId = this.route.snapshot.params.id;
|
||||
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
|
||||
this.bundlesRD$ = this.itemRD$.pipe(
|
||||
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
|
||||
);
|
||||
this.selectedBundleId = this.route.snapshot.queryParams.bundle;
|
||||
if (isNotEmpty(this.selectedBundleId)) {
|
||||
this.bundleService.findById(this.selectedBundleId).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
).subscribe((bundle: Bundle) => {
|
||||
this.selectedBundleName = bundle.name;
|
||||
});
|
||||
this.setUploadUrl();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bundle with the filled in name on the current item
|
||||
*/
|
||||
createBundle() {
|
||||
this.itemService.createBundle(this.itemId, this.selectedBundleName).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
).subscribe((bundle: Bundle) => {
|
||||
this.selectedBundleId = bundle.id;
|
||||
this.notificationsService.success(
|
||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.title'),
|
||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.content')
|
||||
);
|
||||
this.setUploadUrl();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The user changed the bundle name
|
||||
* Reset the bundle ID
|
||||
*/
|
||||
bundleNameChange() {
|
||||
this.selectedBundleId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the upload url to match the selected bundle ID
|
||||
*/
|
||||
setUploadUrl() {
|
||||
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
|
||||
this.uploadFilesOptions.url = href;
|
||||
if (isEmpty(this.uploadFilesOptions.authToken)) {
|
||||
this.uploadFilesOptions.authToken = this.authService.buildAuthHeader();
|
||||
}
|
||||
// Re-initialize the uploader component to ensure the latest changes to the options are applied
|
||||
if (this.uploaderComponent) {
|
||||
this.uploaderComponent.ngOnInit();
|
||||
this.uploaderComponent.ngAfterViewInit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The request was successful, redirect the user to the new bitstream's edit page
|
||||
* @param bitstream
|
||||
*/
|
||||
public onCompleteItem(bitstream) {
|
||||
// Clear cached requests for this bundle's bitstreams to ensure lists on all pages are up-to-date
|
||||
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
|
||||
this.requestService.removeByHrefSubstring(href);
|
||||
});
|
||||
|
||||
// Bring over the item ID as a query parameter
|
||||
const queryParams = { itemId: this.itemId };
|
||||
this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit'], { queryParams: queryParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* The request was unsuccessful, display an error notification
|
||||
*/
|
||||
public onUploadError() {
|
||||
this.notificationsService.error(null, this.translate.get(this.NOTIFICATIONS_PREFIX + 'upload.failed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user selected a bundle from the input suggestions
|
||||
* Set the bundle ID and Name properties, as well as the upload URL
|
||||
* @param bundle
|
||||
*/
|
||||
onClick(bundle: Bundle) {
|
||||
this.selectedBundleId = bundle.id;
|
||||
this.selectedBundleName = bundle.name;
|
||||
this.setUploadUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* When cancel is clicked, navigate back to the item's edit bitstreams page
|
||||
*/
|
||||
onCancel() {
|
||||
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} the current URL
|
||||
*/
|
||||
getCurrentUrl() {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all open subscriptions when the component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs
|
||||
.filter((subscription) => hasValue(subscription))
|
||||
.forEach((subscription) => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
@@ -10,12 +10,16 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component';
|
||||
|
||||
@Injectable()
|
||||
@Component({
|
||||
selector: 'ds-abstract-item-update',
|
||||
template: ''
|
||||
})
|
||||
/**
|
||||
* Abstract component for managing object updates of an item
|
||||
*/
|
||||
export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
export class AbstractItemUpdateComponent extends AbstractTrackableComponent implements OnInit {
|
||||
/**
|
||||
* The item to display the edit page for
|
||||
*/
|
||||
@@ -25,30 +29,17 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
* Should be initialized in the initializeUpdates method of the child component
|
||||
*/
|
||||
updates$: Observable<FieldUpdates>;
|
||||
/**
|
||||
* The current url of this page
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Prefix for this component's notification translate keys
|
||||
* Should be initialized in the initializeNotificationsPrefix method of the child component
|
||||
*/
|
||||
notificationsPrefix;
|
||||
/**
|
||||
* The time span for being able to undo discarding changes
|
||||
*/
|
||||
discardTimeOut: number;
|
||||
|
||||
constructor(
|
||||
protected itemService: ItemDataService,
|
||||
protected objectUpdatesService: ObjectUpdatesService,
|
||||
protected router: Router,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||
protected route: ActivatedRoute
|
||||
public itemService: ItemDataService,
|
||||
public objectUpdatesService: ObjectUpdatesService,
|
||||
public router: Router,
|
||||
public notificationsService: NotificationsService,
|
||||
public translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||
public route: ActivatedRoute
|
||||
) {
|
||||
|
||||
super(objectUpdatesService, notificationsService, translateService)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +52,7 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
map((data: RemoteData<Item>) => data.payload)
|
||||
).subscribe((item: Item) => {
|
||||
this.item = item;
|
||||
this.postItemInit();
|
||||
});
|
||||
|
||||
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
|
||||
@@ -81,19 +73,44 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the values and updates of the current item's fields
|
||||
* Actions to perform after the item has been initialized
|
||||
* Abstract method: Should be overwritten in the sub class
|
||||
*/
|
||||
abstract initializeUpdates(): void;
|
||||
postItemInit(): void {
|
||||
// Overwrite in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the values and updates of the current item's fields
|
||||
* Abstract method: Should be overwritten in the sub class
|
||||
*/
|
||||
initializeUpdates(): void {
|
||||
// Overwrite in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the prefix for notification messages
|
||||
* Abstract method: Should be overwritten in the sub class
|
||||
*/
|
||||
abstract initializeNotificationsPrefix(): void;
|
||||
initializeNotificationsPrefix(): void {
|
||||
// Overwrite in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends all initial values of this item to the object updates service
|
||||
* Abstract method: Should be overwritten in the sub class
|
||||
*/
|
||||
abstract initializeOriginalFields(): void;
|
||||
initializeOriginalFields(): void {
|
||||
// Overwrite in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current changes
|
||||
* Abstract method: Should be overwritten in the sub class
|
||||
*/
|
||||
submit(): void {
|
||||
// Overwrite in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent unnecessary rerendering so fields don't lose focus
|
||||
@@ -102,13 +119,6 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
return update && update.field ? update.field.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not there are currently updates for this item
|
||||
*/
|
||||
hasChanges(): Observable<boolean> {
|
||||
return this.objectUpdatesService.hasUpdates(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current page is entirely valid
|
||||
*/
|
||||
@@ -131,49 +141,4 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current changes
|
||||
*/
|
||||
abstract submit(): void;
|
||||
|
||||
/**
|
||||
* Request the object updates service to discard all current changes to this item
|
||||
* Shows a notification to remind the user that they can undo this
|
||||
*/
|
||||
discard() {
|
||||
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
|
||||
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the object updates service to undo discarding all changes to this item
|
||||
*/
|
||||
reinstate() {
|
||||
this.objectUpdatesService.reinstateFieldUpdates(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not the item is currently reinstatable
|
||||
*/
|
||||
isReinstatable(): Observable<boolean> {
|
||||
return this.objectUpdatesService.isReinstatable(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated notification title
|
||||
* @param key
|
||||
*/
|
||||
protected getNotificationTitle(key: string) {
|
||||
return this.translateService.instant(this.notificationsPrefix + key + '.title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated notification content
|
||||
* @param key
|
||||
*/
|
||||
protected getNotificationContent(key: string) {
|
||||
return this.translateService.instant(this.notificationsPrefix + key + '.content');
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -15,12 +15,19 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||
import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
|
||||
import { SearchPageModule } from '../../+search-page/search-page.module';
|
||||
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
|
||||
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
|
||||
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
|
||||
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
|
||||
import { AbstractItemUpdateComponent } from './abstract-item-update/abstract-item-update.component';
|
||||
import { ItemMoveComponent } from './item-move/item-move.component';
|
||||
import { ItemEditBitstreamBundleComponent } from './item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component';
|
||||
import { BundleDataService } from '../../core/data/bundle-data.service';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component';
|
||||
import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component';
|
||||
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
|
||||
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
|
||||
|
||||
@@ -32,12 +39,14 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
EditItemPageRoutingModule,
|
||||
SearchPageModule
|
||||
SearchPageModule,
|
||||
DragDropModule
|
||||
],
|
||||
declarations: [
|
||||
EditItemPageComponent,
|
||||
ItemOperationComponent,
|
||||
AbstractSimpleItemActionComponent,
|
||||
AbstractItemUpdateComponent,
|
||||
ModifyItemOverviewComponent,
|
||||
ItemWithdrawComponent,
|
||||
ItemReinstateComponent,
|
||||
@@ -50,11 +59,19 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version
|
||||
ItemBitstreamsComponent,
|
||||
ItemVersionHistoryComponent,
|
||||
EditInPlaceFieldComponent,
|
||||
ItemEditBitstreamComponent,
|
||||
ItemEditBitstreamBundleComponent,
|
||||
PaginatedDragAndDropBitstreamListComponent,
|
||||
EditInPlaceFieldComponent,
|
||||
EditRelationshipComponent,
|
||||
EditRelationshipListComponent,
|
||||
ItemCollectionMapperComponent,
|
||||
ItemMoveComponent,
|
||||
ItemEditBitstreamDragHandleComponent,
|
||||
VirtualMetadataComponent,
|
||||
],
|
||||
providers: [
|
||||
BundleDataService
|
||||
]
|
||||
})
|
||||
export class EditItemPageModule {
|
||||
|
@@ -1,3 +1,68 @@
|
||||
<div>
|
||||
<div class="item-bitstreams" *ngVar="(bundles$ | async) as bundles">
|
||||
<div class="button-row top d-flex mt-2">
|
||||
<button class="mr-auto btn btn-success"
|
||||
[routerLink]="['/items/', item.id, 'bitstreams', 'new']"><i
|
||||
class="fas fa-upload"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.upload-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-danger mr-1" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async) || submitting"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning mr-1" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || submitting"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="item && bundles?.length > 0" class="container table-bordered mt-4">
|
||||
<div class="row header-row font-weight-bold">
|
||||
<div class="{{columnSizes.columns[0].buildClasses()}} row-element">
|
||||
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||
{{'item.edit.bitstreams.headers.name' | translate}}
|
||||
</div>
|
||||
<div class="{{columnSizes.columns[1].buildClasses()}} row-element">{{'item.edit.bitstreams.headers.description' | translate}}</div>
|
||||
<div class="{{columnSizes.columns[2].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.format' | translate}}</div>
|
||||
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.actions' | translate}}</div>
|
||||
</div>
|
||||
<ds-item-edit-bitstream-bundle *ngFor="let bundle of bundles"
|
||||
[bundle]="bundle"
|
||||
[item]="item"
|
||||
[columnSizes]="columnSizes">
|
||||
</ds-item-edit-bitstream-bundle>
|
||||
</div>
|
||||
<div *ngIf="bundles?.length === 0"
|
||||
class="alert alert-info w-100 d-inline-block mt-4" role="alert">
|
||||
{{'item.edit.bitstreams.empty' | translate}}
|
||||
</div>
|
||||
<ds-loading *ngIf="!bundles" message="{{'loading.bitstreams' | translate}}"></ds-loading>
|
||||
|
||||
<div class="button-row bottom">
|
||||
<div class="mt-4 float-right">
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async) || submitting"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || submitting"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,42 @@
|
||||
.header-row {
|
||||
color: $table-dark-color;
|
||||
background-color: $table-dark-bg;
|
||||
border-color: $table-dark-border-color;
|
||||
}
|
||||
|
||||
.bundle-row {
|
||||
color: $table-head-color;
|
||||
background-color: $table-head-bg;
|
||||
border-color: $table-border-color;
|
||||
}
|
||||
|
||||
.row-element {
|
||||
padding: 12px;
|
||||
padding: 0.75em;
|
||||
border-bottom: $table-border-width solid $table-border-color;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
visibility: hidden;
|
||||
&:hover {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .bitstream-row:hover .drag-handle {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
margin-left: 0;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
@@ -0,0 +1,224 @@
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ItemBitstreamsComponent } from './item-bitstreams.component';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { GLOBAL_CONFIG } from '../../../../config';
|
||||
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
|
||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
||||
import { getMockRequestService } from '../../../shared/mocks/mock-request.service';
|
||||
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||
import { Bundle } from '../../../core/shared/bundle.model';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
|
||||
|
||||
let comp: ItemBitstreamsComponent;
|
||||
let fixture: ComponentFixture<ItemBitstreamsComponent>;
|
||||
|
||||
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||
const bitstream1 = Object.assign(new Bitstream(), {
|
||||
id: 'bitstream1',
|
||||
uuid: 'bitstream1'
|
||||
});
|
||||
const bitstream2 = Object.assign(new Bitstream(), {
|
||||
id: 'bitstream2',
|
||||
uuid: 'bitstream2'
|
||||
});
|
||||
const fieldUpdate1 = {
|
||||
field: bitstream1,
|
||||
changeType: undefined
|
||||
};
|
||||
const fieldUpdate2 = {
|
||||
field: bitstream2,
|
||||
changeType: FieldChangeType.REMOVE
|
||||
};
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
id: 'bundle1',
|
||||
uuid: 'bundle1',
|
||||
_links: {
|
||||
self: { href: 'bundle1-selflink' }
|
||||
},
|
||||
bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2])
|
||||
});
|
||||
const moveOperations = [
|
||||
{
|
||||
op: 'move',
|
||||
from: '/0',
|
||||
path: '/1'
|
||||
}
|
||||
];
|
||||
const date = new Date();
|
||||
const url = 'thisUrl';
|
||||
let item: Item;
|
||||
let itemService: ItemDataService;
|
||||
let objectUpdatesService: ObjectUpdatesService;
|
||||
let router: any;
|
||||
let route: ActivatedRoute;
|
||||
let notificationsService: NotificationsService;
|
||||
let bitstreamService: BitstreamDataService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let requestService: RequestService;
|
||||
let searchConfig: SearchConfigurationService;
|
||||
let bundleService: BundleDataService;
|
||||
|
||||
describe('ItemBitstreamsComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||
{
|
||||
getFieldUpdates: observableOf({
|
||||
[bitstream1.uuid]: fieldUpdate1,
|
||||
[bitstream2.uuid]: fieldUpdate2,
|
||||
}),
|
||||
getFieldUpdatesExclusive: observableOf({
|
||||
[bitstream1.uuid]: fieldUpdate1,
|
||||
[bitstream2.uuid]: fieldUpdate2,
|
||||
}),
|
||||
saveAddFieldUpdate: {},
|
||||
discardFieldUpdates: {},
|
||||
discardAllFieldUpdates: {},
|
||||
reinstateFieldUpdates: observableOf(true),
|
||||
initialize: {},
|
||||
getUpdatedFields: observableOf([bitstream1, bitstream2]),
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
isReinstatable: observableOf(false),
|
||||
isValidPage: observableOf(true),
|
||||
getMoveOperations: observableOf(moveOperations)
|
||||
}
|
||||
);
|
||||
router = Object.assign(new RouterStub(), {
|
||||
url: url
|
||||
});
|
||||
notificationsService = jasmine.createSpyObj('notificationsService',
|
||||
{
|
||||
info: infoNotification,
|
||||
warning: warningNotification,
|
||||
success: successNotification
|
||||
}
|
||||
);
|
||||
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||
deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse')
|
||||
});
|
||||
objectCache = jasmine.createSpyObj('objectCache', {
|
||||
remove: jasmine.createSpy('remove')
|
||||
});
|
||||
requestService = getMockRequestService();
|
||||
searchConfig = Object.assign( {
|
||||
paginatedSearchOptions: observableOf({})
|
||||
});
|
||||
|
||||
item = Object.assign(new Item(), {
|
||||
uuid: 'item',
|
||||
id: 'item',
|
||||
_links: {
|
||||
self: { href: 'item-selflink' }
|
||||
},
|
||||
bundles: createMockRDPaginatedObs([bundle]),
|
||||
lastModified: date
|
||||
});
|
||||
itemService = Object.assign( {
|
||||
getBitstreams: () => createMockRDPaginatedObs([bitstream1, bitstream2]),
|
||||
findById: () => createMockRDObs(item),
|
||||
getBundles: () => createMockRDPaginatedObs([bundle])
|
||||
});
|
||||
route = Object.assign({
|
||||
parent: {
|
||||
data: observableOf({ item: createMockRD(item) })
|
||||
},
|
||||
url: url
|
||||
});
|
||||
bundleService = jasmine.createSpyObj('bundleService', {
|
||||
patch: observableOf(new RestResponse(true, 200, 'OK'))
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective],
|
||||
providers: [
|
||||
{ provide: ItemDataService, useValue: itemService },
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
|
||||
{ provide: BitstreamDataService, useValue: bitstreamService },
|
||||
{ provide: ObjectCacheService, useValue: objectCache },
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: SearchConfigurationService, useValue: searchConfig },
|
||||
{ provide: BundleDataService, useValue: bundleService },
|
||||
ChangeDetectorRef
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemBitstreamsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.url = url;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when submit is called', () => {
|
||||
beforeEach(() => {
|
||||
comp.submit();
|
||||
});
|
||||
|
||||
it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => {
|
||||
expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id);
|
||||
});
|
||||
|
||||
it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => {
|
||||
expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id);
|
||||
});
|
||||
|
||||
it('should send out a patch for the move operations', () => {
|
||||
expect(bundleService.patch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discard', () => {
|
||||
it('should discard ALL field updates', () => {
|
||||
comp.discard();
|
||||
expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinstate', () => {
|
||||
it('should reinstate field updates on the bundle', () => {
|
||||
comp.reinstate();
|
||||
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export function createMockRDPaginatedObs(list: any[]) {
|
||||
return createMockRDObs(new PaginatedList(new PageInfo(), list));
|
||||
}
|
||||
|
||||
export function createMockRDObs(obj: any) {
|
||||
return observableOf(createMockRD(obj));
|
||||
}
|
||||
|
||||
export function createMockRD(obj: any) {
|
||||
return new RemoteData(false, false, true, null, obj);
|
||||
}
|
@@ -1,4 +1,34 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
|
||||
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
|
||||
import { filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
||||
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
|
||||
import { zip as observableZip, combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
|
||||
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { Bundle } from '../../../core/shared/bundle.model';
|
||||
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
||||
import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||
import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-bitstreams',
|
||||
@@ -8,6 +38,273 @@ import { Component } from '@angular/core';
|
||||
/**
|
||||
* Component for displaying an item's bitstreams edit page
|
||||
*/
|
||||
export class ItemBitstreamsComponent {
|
||||
/* TODO implement */
|
||||
export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy {
|
||||
|
||||
/**
|
||||
* The currently listed bundles
|
||||
*/
|
||||
bundles$: Observable<Bundle[]>;
|
||||
|
||||
/**
|
||||
* The page options to use for fetching the bundles
|
||||
*/
|
||||
bundlesOptions = {
|
||||
id: 'bundles-pagination-options',
|
||||
currentPage: 1,
|
||||
pageSize: 9999
|
||||
} as any;
|
||||
|
||||
/**
|
||||
* The bootstrap sizes used for the columns within this table
|
||||
*/
|
||||
columnSizes = new ResponsiveTableSizes([
|
||||
// Name column
|
||||
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||
// Description column
|
||||
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||
// Format column
|
||||
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||
// Actions column
|
||||
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||
]);
|
||||
|
||||
/**
|
||||
* Are we currently submitting the changes?
|
||||
* Used to disable any action buttons until the submit finishes
|
||||
*/
|
||||
submitting = false;
|
||||
|
||||
/**
|
||||
* A subscription that checks when the item is deleted in cache and reloads the item by sending a new request
|
||||
* This is used to update the item in cache after bitstreams are deleted
|
||||
*/
|
||||
itemUpdateSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
public itemService: ItemDataService,
|
||||
public objectUpdatesService: ObjectUpdatesService,
|
||||
public router: Router,
|
||||
public notificationsService: NotificationsService,
|
||||
public translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||
public route: ActivatedRoute,
|
||||
public bitstreamService: BitstreamDataService,
|
||||
public objectCache: ObjectCacheService,
|
||||
public requestService: RequestService,
|
||||
public cdRef: ChangeDetectorRef,
|
||||
public bundleService: BundleDataService
|
||||
) {
|
||||
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up and initialize all fields
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.initializeItemUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions to perform after the item has been initialized
|
||||
*/
|
||||
postItemInit(): void {
|
||||
this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: this.bundlesOptions})).pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((bundlePage: PaginatedList<Bundle>) => bundlePage.page)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the notification messages prefix
|
||||
*/
|
||||
initializeNotificationsPrefix(): void {
|
||||
this.notificationsPrefix = 'item.edit.bitstreams.notifications.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the item (and view) when it's removed in the request cache
|
||||
* Also re-initialize the original fields and updates
|
||||
*/
|
||||
initializeItemUpdate(): void {
|
||||
this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe(
|
||||
filter((exists: boolean) => !exists),
|
||||
switchMap(() => this.itemService.findById(this.item.uuid)),
|
||||
getSucceededRemoteData(),
|
||||
).subscribe((itemRD: RemoteData<Item>) => {
|
||||
if (hasValue(itemRD)) {
|
||||
this.item = itemRD.payload;
|
||||
this.postItemInit();
|
||||
this.initializeOriginalFields();
|
||||
this.initializeUpdates();
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current changes
|
||||
* Bitstreams that were dragged around send out a patch request with move operations to the rest API
|
||||
* Bitstreams marked as deleted send out a delete request to the rest API
|
||||
* Display notifications and reset the current item/updates
|
||||
*/
|
||||
submit() {
|
||||
this.submitting = true;
|
||||
const bundlesOnce$ = this.bundles$.pipe(take(1));
|
||||
|
||||
// Fetch all move operations for each bundle
|
||||
const moveOperations$ = bundlesOnce$.pipe(
|
||||
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) =>
|
||||
this.objectUpdatesService.getMoveOperations(bundle.self).pipe(
|
||||
take(1),
|
||||
map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, {
|
||||
from: `/_links/bitstreams${operation.from}/href`,
|
||||
path: `/_links/bitstreams${operation.path}/href`
|
||||
}))])
|
||||
)
|
||||
)))
|
||||
);
|
||||
|
||||
// Send out an immediate patch request for each bundle
|
||||
const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe(
|
||||
switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) =>
|
||||
observableZip(...bundles.map((bundle: Bundle, index: number) => {
|
||||
if (isNotEmpty(moveOperationList[index])) {
|
||||
return this.bundleService.patch(bundle, moveOperationList[index]);
|
||||
} else {
|
||||
return observableOf(undefined);
|
||||
}
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// Fetch all removed bitstreams from the object update service
|
||||
const removedBitstreams$ = bundlesOnce$.pipe(
|
||||
switchMap((bundles: Bundle[]) => observableZip(
|
||||
...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true))
|
||||
)),
|
||||
map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat(
|
||||
...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE))
|
||||
)),
|
||||
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field))
|
||||
);
|
||||
|
||||
// Send out delete requests for all deleted bitstreams
|
||||
const removedResponses$ = removedBitstreams$.pipe(
|
||||
take(1),
|
||||
switchMap((removedBistreams: Bitstream[]) => {
|
||||
if (isNotEmpty(removedBistreams)) {
|
||||
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id)));
|
||||
} else {
|
||||
return observableOf(undefined);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Perform the setup actions from above in order and display notifications
|
||||
patchResponses$.pipe(
|
||||
switchMap((responses: RestResponse[]) => {
|
||||
this.displayNotifications('item.edit.bitstreams.notifications.move', responses);
|
||||
return removedResponses$
|
||||
}),
|
||||
take(1)
|
||||
).subscribe((responses: RestResponse[]) => {
|
||||
this.displayNotifications('item.edit.bitstreams.notifications.remove', responses);
|
||||
this.reset();
|
||||
this.submitting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display notifications
|
||||
* - Error notification for each failed response with their message
|
||||
* - Success notification in case there's at least one successful response
|
||||
* @param key The i18n key for the notification messages
|
||||
* @param responses The returned responses to display notifications for
|
||||
*/
|
||||
displayNotifications(key: string, responses: RestResponse[]) {
|
||||
if (isNotEmpty(responses)) {
|
||||
const failedResponses = responses.filter((response: RestResponse) => hasValue(response) && !response.isSuccessful);
|
||||
const successfulResponses = responses.filter((response: RestResponse) => hasValue(response) && response.isSuccessful);
|
||||
|
||||
failedResponses.forEach((response: ErrorResponse) => {
|
||||
this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage);
|
||||
});
|
||||
if (successfulResponses.length > 0) {
|
||||
this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the object updates service to discard all current changes to this item
|
||||
* Shows a notification to remind the user that they can undo this
|
||||
*/
|
||||
discard() {
|
||||
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), {timeOut: this.discardTimeOut});
|
||||
this.objectUpdatesService.discardAllFieldUpdates(this.url, undoNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the object updates service to undo discarding all changes to this item
|
||||
*/
|
||||
reinstate() {
|
||||
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
|
||||
bundles.forEach((bundle: Bundle) => {
|
||||
this.objectUpdatesService.reinstateFieldUpdates(bundle.self);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not the object is currently reinstatable
|
||||
*/
|
||||
isReinstatable(): Observable<boolean> {
|
||||
return this.bundles$.pipe(
|
||||
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)))),
|
||||
map((reinstatable: boolean[]) => reinstatable.includes(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not there are currently updates for this object
|
||||
*/
|
||||
hasChanges(): Observable<boolean> {
|
||||
return this.bundles$.pipe(
|
||||
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)))),
|
||||
map((hasChanges: boolean[]) => hasChanges.includes(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* De-cache the current item (it should automatically reload due to itemUpdateSubscription)
|
||||
*/
|
||||
reset() {
|
||||
this.refreshItemCache();
|
||||
this.initializeItemUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the current item's cache from object- and request-cache
|
||||
*/
|
||||
refreshItemCache() {
|
||||
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
|
||||
bundles.forEach((bundle: Bundle) => {
|
||||
this.objectCache.remove(bundle.self);
|
||||
this.requestService.removeByHrefSubstring(bundle.self);
|
||||
});
|
||||
this.objectCache.remove(this.item.self);
|
||||
this.requestService.removeByHrefSubstring(this.item.self);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from open subscriptions whenever the component gets destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.itemUpdateSubscription) {
|
||||
this.itemUpdateSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,21 @@
|
||||
<ng-template #bundleView>
|
||||
<div class="row bundle-row">
|
||||
<div class="{{bundleNameColumn.buildClasses()}} font-weight-bold row-element d-flex">
|
||||
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||
<div class="float-left d-flex align-items-center">
|
||||
{{'item.edit.bitstreams.bundle.name' | translate:{ name: bundle.name } }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">
|
||||
<div class="btn-group bundle-action-buttons">
|
||||
<button [routerLink]="['/items/', item.id, 'bitstreams', 'new']"
|
||||
[queryParams]="{bundle: bundle.id}"
|
||||
class="btn btn-outline-success btn-sm"
|
||||
title="{{'item.edit.bitstreams.bundle.edit.buttons.upload' | translate}}">
|
||||
<i class="fas fa-upload fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-paginated-drag-and-drop-bitstream-list [bundle]="bundle" [columnSizes]="columnSizes"></ds-paginated-drag-and-drop-bitstream-list>
|
||||
</ng-template>
|
@@ -0,0 +1,58 @@
|
||||
import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { Bundle } from '../../../../core/shared/bundle.model';
|
||||
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||
|
||||
describe('ItemEditBitstreamBundleComponent', () => {
|
||||
let comp: ItemEditBitstreamBundleComponent;
|
||||
let fixture: ComponentFixture<ItemEditBitstreamBundleComponent>;
|
||||
let viewContainerRef: ViewContainerRef;
|
||||
|
||||
const columnSizes = new ResponsiveTableSizes([
|
||||
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||
]);
|
||||
|
||||
const item = Object.assign(new Item(), {
|
||||
id: 'item-1',
|
||||
uuid: 'item-1'
|
||||
});
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
id: 'bundle-1',
|
||||
uuid: 'bundle-1',
|
||||
_links: {
|
||||
self: { href: 'bundle-1-selflink' }
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ItemEditBitstreamBundleComponent],
|
||||
schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemEditBitstreamBundleComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.item = item;
|
||||
comp.bundle = bundle;
|
||||
comp.columnSizes = columnSizes;
|
||||
viewContainerRef = (comp as any).viewContainerRef;
|
||||
spyOn(viewContainerRef, 'createEmbeddedView');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create an embedded view of the component', () => {
|
||||
expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
@@ -0,0 +1,52 @@
|
||||
import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { Bundle } from '../../../../core/shared/bundle.model';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-edit-bitstream-bundle',
|
||||
styleUrls: ['../item-bitstreams.component.scss'],
|
||||
templateUrl: './item-edit-bitstream-bundle.component.html',
|
||||
})
|
||||
/**
|
||||
* Component that displays a single bundle of an item on the item bitstreams edit page
|
||||
* Creates an embedded view of the contents. This is to ensure the table structure won't break.
|
||||
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element)
|
||||
*/
|
||||
export class ItemEditBitstreamBundleComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The view on the bundle information and bitstreams
|
||||
*/
|
||||
@ViewChild('bundleView', {static: true}) bundleView;
|
||||
|
||||
/**
|
||||
* The bundle to display bitstreams for
|
||||
*/
|
||||
@Input() bundle: Bundle;
|
||||
|
||||
/**
|
||||
* The item the bundle belongs to
|
||||
*/
|
||||
@Input() item: Item;
|
||||
|
||||
/**
|
||||
* The bootstrap sizes used for the columns within this table
|
||||
*/
|
||||
@Input() columnSizes: ResponsiveTableSizes;
|
||||
|
||||
/**
|
||||
* The bootstrap sizes used for the Bundle Name column
|
||||
* This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit
|
||||
*/
|
||||
bundleNameColumn: ResponsiveColumnSizes;
|
||||
|
||||
constructor(private viewContainerRef: ViewContainerRef) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.bundleNameColumn = this.columnSizes.combineColumns(0, 2);
|
||||
this.viewContainerRef.createEmbeddedView(this.bundleView);
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
<ds-pagination *ngIf="(objectsRD$ | async)?.payload"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[hidePaginationDetail]="true"
|
||||
[paginationOptions]="options"
|
||||
[pageInfoState]="(objectsRD$ | async)?.payload"
|
||||
[collectionSize]="(objectsRD$ | async)?.payload?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchPage($event)">
|
||||
<div [id]="bundle.id" class="bundle-bitstreams-list"
|
||||
[ngClass]="{'mb-3': (objectsRD$ | async)?.payload?.totalElements > pageSize}"
|
||||
*ngVar="((updates$ | async) | dsObjectValues) as updateValues" cdkDropList (cdkDropListDropped)="drop($event)">
|
||||
<div class="row bitstream-row" *ngFor="let updateValue of updateValues" cdkDrag
|
||||
[id]="updateValue.field.uuid"
|
||||
[ngClass]="{
|
||||
'table-warning': updateValue.changeType === 0,
|
||||
'table-danger': updateValue.changeType === 2,
|
||||
'table-success': updateValue.changeType === 1,
|
||||
'bg-white': updateValue.changeType === undefined
|
||||
}">
|
||||
<ds-item-edit-bitstream [fieldUpdate]="updateValue"
|
||||
[bundleUrl]="bundle.self"
|
||||
[columnSizes]="columnSizes">
|
||||
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle>
|
||||
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||
</div>
|
||||
</ds-item-edit-bitstream>
|
||||
</div>
|
||||
</div>
|
||||
</ds-pagination>
|
@@ -0,0 +1,132 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { Bundle } from '../../../../../core/shared/bundle.model';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list.component';
|
||||
import { VarDirective } from '../../../../../shared/utils/var.directive';
|
||||
import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe';
|
||||
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
|
||||
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
|
||||
import { createMockRDObs } from '../../item-bitstreams.component.spec';
|
||||
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||
import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model';
|
||||
import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||
|
||||
describe('PaginatedDragAndDropBitstreamListComponent', () => {
|
||||
let comp: PaginatedDragAndDropBitstreamListComponent;
|
||||
let fixture: ComponentFixture<PaginatedDragAndDropBitstreamListComponent>;
|
||||
let objectUpdatesService: ObjectUpdatesService;
|
||||
let bundleService: BundleDataService;
|
||||
|
||||
const columnSizes = new ResponsiveTableSizes([
|
||||
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||
]);
|
||||
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
id: 'bundle-1',
|
||||
uuid: 'bundle-1',
|
||||
_links: {
|
||||
self: { href: 'bundle-1-selflink' }
|
||||
}
|
||||
});
|
||||
const date = new Date();
|
||||
const format = Object.assign(new BitstreamFormat(), {
|
||||
shortDescription: 'PDF'
|
||||
});
|
||||
const bitstream1 = Object.assign(new Bitstream(), {
|
||||
uuid: 'bitstreamUUID1',
|
||||
name: 'Fake Bitstream 1',
|
||||
bundleName: 'ORIGINAL',
|
||||
description: 'Description',
|
||||
format: createMockRDObs(format)
|
||||
});
|
||||
const fieldUpdate1 = {
|
||||
field: bitstream1,
|
||||
changeType: undefined
|
||||
};
|
||||
const bitstream2 = Object.assign(new Bitstream(), {
|
||||
uuid: 'bitstreamUUID2',
|
||||
name: 'Fake Bitstream 2',
|
||||
bundleName: 'ORIGINAL',
|
||||
description: 'Description',
|
||||
format: createMockRDObs(format)
|
||||
});
|
||||
const fieldUpdate2 = {
|
||||
field: bitstream2,
|
||||
changeType: undefined
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||
{
|
||||
getFieldUpdates: observableOf({
|
||||
[bitstream1.uuid]: fieldUpdate1,
|
||||
[bitstream2.uuid]: fieldUpdate2,
|
||||
}),
|
||||
getFieldUpdatesExclusive: observableOf({
|
||||
[bitstream1.uuid]: fieldUpdate1,
|
||||
[bitstream2.uuid]: fieldUpdate2,
|
||||
}),
|
||||
getFieldUpdatesByCustomOrder: observableOf({
|
||||
[bitstream1.uuid]: fieldUpdate1,
|
||||
[bitstream2.uuid]: fieldUpdate2,
|
||||
}),
|
||||
saveMoveFieldUpdate: {},
|
||||
saveRemoveFieldUpdate: {},
|
||||
removeSingleFieldUpdate: {},
|
||||
saveAddFieldUpdate: {},
|
||||
discardFieldUpdates: {},
|
||||
reinstateFieldUpdates: observableOf(true),
|
||||
initialize: {},
|
||||
getUpdatedFields: observableOf([bitstream1, bitstream2]),
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
isReinstatable: observableOf(false),
|
||||
isValidPage: observableOf(true),
|
||||
initializeWithCustomOrder: {},
|
||||
addPageToCustomOrder: {}
|
||||
}
|
||||
);
|
||||
|
||||
bundleService = jasmine.createSpyObj('bundleService', {
|
||||
getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2]))
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe],
|
||||
providers: [
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||
{ provide: BundleDataService, useValue: bundleService }
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PaginatedDragAndDropBitstreamListComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.bundle = bundle;
|
||||
comp.columnSizes = columnSizes;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should initialize the objectsRD$', (done) => {
|
||||
comp.objectsRD$.pipe(take(1)).subscribe((objects) => {
|
||||
expect(objects.payload.page).toEqual([bitstream1, bitstream2]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize the URL', () => {
|
||||
expect(comp.url).toEqual(bundle.self);
|
||||
});
|
||||
});
|
@@ -0,0 +1,63 @@
|
||||
import { AbstractPaginatedDragAndDropListComponent } from '../../../../../shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component';
|
||||
import { Component, ElementRef, Input, OnInit } from '@angular/core';
|
||||
import { Bundle } from '../../../../../core/shared/bundle.model';
|
||||
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
|
||||
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model';
|
||||
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
import { followLink } from '../../../../../shared/utils/follow-link-config.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-paginated-drag-and-drop-bitstream-list',
|
||||
styleUrls: ['../../item-bitstreams.component.scss'],
|
||||
templateUrl: './paginated-drag-and-drop-bitstream-list.component.html',
|
||||
})
|
||||
/**
|
||||
* A component listing edit-bitstream rows for each bitstream within the given bundle.
|
||||
* This component makes use of the AbstractPaginatedDragAndDropListComponent, allowing for users to drag and drop
|
||||
* bitstreams within the paginated list. To drag and drop a bitstream between two pages, drag the row on top of the
|
||||
* page number you want the bitstream to end up at. Doing so will add the bitstream to the top of that page.
|
||||
*/
|
||||
export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginatedDragAndDropListComponent<Bitstream> implements OnInit {
|
||||
/**
|
||||
* The bundle to display bitstreams for
|
||||
*/
|
||||
@Input() bundle: Bundle;
|
||||
|
||||
/**
|
||||
* The bootstrap sizes used for the columns within this table
|
||||
*/
|
||||
@Input() columnSizes: ResponsiveTableSizes;
|
||||
|
||||
constructor(protected objectUpdatesService: ObjectUpdatesService,
|
||||
protected elRef: ElementRef,
|
||||
protected bundleService: BundleDataService) {
|
||||
super(objectUpdatesService, elRef);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
super.ngOnInit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the bitstreams observable depending on currentPage$
|
||||
*/
|
||||
initializeObjectsRD(): void {
|
||||
this.objectsRD$ = this.currentPage$.pipe(
|
||||
switchMap((page: number) => this.bundleService.getBitstreams(
|
||||
this.bundle.id,
|
||||
new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
|
||||
followLink('format')
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the URL used for the field-update store, in this case the bundle's self-link
|
||||
*/
|
||||
initializeURL(): void {
|
||||
this.url = this.bundle.self;
|
||||
}
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
<ng-template #handleView>
|
||||
<div class="drag-handle text-muted float-left p-1 mr-2">
|
||||
<i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,26 @@
|
||||
import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-edit-bitstream-drag-handle',
|
||||
styleUrls: ['../item-bitstreams.component.scss'],
|
||||
templateUrl: './item-edit-bitstream-drag-handle.component.html',
|
||||
})
|
||||
/**
|
||||
* Component displaying a drag handle for the item-edit-bitstream page
|
||||
* Creates an embedded view of the contents
|
||||
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element)
|
||||
*/
|
||||
export class ItemEditBitstreamDragHandleComponent implements OnInit {
|
||||
/**
|
||||
* The view on the drag-handle
|
||||
*/
|
||||
@ViewChild('handleView', {static: true}) handleView;
|
||||
|
||||
constructor(private viewContainerRef: ViewContainerRef) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.viewContainerRef.createEmbeddedView(this.handleView);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,43 @@
|
||||
<ng-template #bitstreamView>
|
||||
<div class="{{columnSizes.columns[0].buildClasses()}} row-element d-flex">
|
||||
<ng-content select="[slot=drag-handle]"></ng-content>
|
||||
<div class="float-left d-flex align-items-center">
|
||||
{{ bitstreamName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{columnSizes.columns[1].buildClasses()}} row-element d-flex align-items-center">
|
||||
<div class="w-100">
|
||||
{{ bitstream?.firstMetadataValue('dc.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{columnSizes.columns[2].buildClasses()}} row-element d-flex align-items-center">
|
||||
<div class="text-center w-100">
|
||||
{{ (format$ | async)?.shortDescription }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
|
||||
<div class="text-center w-100">
|
||||
<div class="btn-group relationship-action-buttons">
|
||||
<a [href]="bitstream?._links?.content?.href"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}">
|
||||
<i class="fas fa-download fa-fw"></i>
|
||||
</a>
|
||||
<button [routerLink]="['/bitstreams/', bitstream.id, 'edit']" class="btn btn-outline-primary btn-sm"
|
||||
title="{{'item.edit.bitstreams.edit.buttons.edit' | translate}}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button [disabled]="!canRemove()" (click)="remove()"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="{{'item.edit.bitstreams.edit.buttons.remove' | translate}}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
<button [disabled]="!canUndo()" (click)="undo()"
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
title="{{'item.edit.bitstreams.edit.buttons.undo' | translate}}">
|
||||
<i class="fas fa-undo-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,119 @@
|
||||
import { ItemEditBitstreamComponent } from './item-edit-bitstream.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { VarDirective } from '../../../../shared/utils/var.directive';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { createMockRDObs } from '../item-bitstreams.component.spec';
|
||||
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
|
||||
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||
|
||||
let comp: ItemEditBitstreamComponent;
|
||||
let fixture: ComponentFixture<ItemEditBitstreamComponent>;
|
||||
|
||||
const columnSizes = new ResponsiveTableSizes([
|
||||
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||
]);
|
||||
|
||||
const format = Object.assign(new BitstreamFormat(), {
|
||||
shortDescription: 'PDF'
|
||||
});
|
||||
const bitstream = Object.assign(new Bitstream(), {
|
||||
uuid: 'bitstreamUUID',
|
||||
name: 'Fake Bitstream',
|
||||
bundleName: 'ORIGINAL',
|
||||
description: 'Description',
|
||||
format: createMockRDObs(format)
|
||||
});
|
||||
const fieldUpdate = {
|
||||
field: bitstream,
|
||||
changeType: undefined
|
||||
};
|
||||
const date = new Date();
|
||||
const url = 'thisUrl';
|
||||
|
||||
let objectUpdatesService: ObjectUpdatesService;
|
||||
|
||||
describe('ItemEditBitstreamComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||
{
|
||||
getFieldUpdates: observableOf({
|
||||
[bitstream.uuid]: fieldUpdate,
|
||||
}),
|
||||
getFieldUpdatesExclusive: observableOf({
|
||||
[bitstream.uuid]: fieldUpdate,
|
||||
}),
|
||||
saveRemoveFieldUpdate: {},
|
||||
removeSingleFieldUpdate: {},
|
||||
saveAddFieldUpdate: {},
|
||||
discardFieldUpdates: {},
|
||||
reinstateFieldUpdates: observableOf(true),
|
||||
initialize: {},
|
||||
getUpdatedFields: observableOf([bitstream]),
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
isReinstatable: observableOf(false),
|
||||
isValidPage: observableOf(true)
|
||||
}
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ItemEditBitstreamComponent, VarDirective],
|
||||
providers: [
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemEditBitstreamComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.fieldUpdate = fieldUpdate;
|
||||
comp.bundleUrl = url;
|
||||
comp.columnSizes = columnSizes;
|
||||
comp.ngOnChanges(undefined);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when remove is called', () => {
|
||||
beforeEach(() => {
|
||||
comp.remove();
|
||||
});
|
||||
|
||||
it('should call saveRemoveFieldUpdate on objectUpdatesService', () => {
|
||||
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, bitstream);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when undo is called', () => {
|
||||
beforeEach(() => {
|
||||
comp.undo();
|
||||
});
|
||||
|
||||
it('should call removeSingleFieldUpdate on objectUpdatesService', () => {
|
||||
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, bitstream.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canRemove is called', () => {
|
||||
it('should return true', () => {
|
||||
expect(comp.canRemove()).toEqual(true)
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canUndo is called', () => {
|
||||
it('should return false', () => {
|
||||
expect(comp.canUndo()).toEqual(false)
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,110 @@
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-edit-bitstream',
|
||||
styleUrls: ['../item-bitstreams.component.scss'],
|
||||
templateUrl: './item-edit-bitstream.component.html',
|
||||
})
|
||||
/**
|
||||
* Component that displays a single bitstream of an item on the edit page
|
||||
* Creates an embedded view of the contents
|
||||
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element)
|
||||
*/
|
||||
export class ItemEditBitstreamComponent implements OnChanges, OnInit {
|
||||
|
||||
/**
|
||||
* The view on the bitstream
|
||||
*/
|
||||
@ViewChild('bitstreamView', {static: true}) bitstreamView;
|
||||
|
||||
/**
|
||||
* The current field, value and state of the bitstream
|
||||
*/
|
||||
@Input() fieldUpdate: FieldUpdate;
|
||||
|
||||
/**
|
||||
* The url of the bundle
|
||||
*/
|
||||
@Input() bundleUrl: string;
|
||||
|
||||
/**
|
||||
* The bootstrap sizes used for the columns within this table
|
||||
*/
|
||||
@Input() columnSizes: ResponsiveTableSizes;
|
||||
|
||||
/**
|
||||
* The bitstream of this field
|
||||
*/
|
||||
bitstream: Bitstream;
|
||||
|
||||
/**
|
||||
* The bitstream's name
|
||||
*/
|
||||
bitstreamName: string;
|
||||
|
||||
/**
|
||||
* The format of the bitstream
|
||||
*/
|
||||
format$: Observable<BitstreamFormat>;
|
||||
|
||||
constructor(private objectUpdatesService: ObjectUpdatesService,
|
||||
private dsoNameService: DSONameService,
|
||||
private viewContainerRef: ViewContainerRef) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.viewContainerRef.createEmbeddedView(this.bitstreamView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current bitstream and its format on changes
|
||||
* @param changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream;
|
||||
this.bitstreamName = this.dsoNameService.getName(this.bitstream);
|
||||
this.format$ = this.bitstream.format.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new remove update for this field to the object updates service
|
||||
*/
|
||||
remove(): void {
|
||||
this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, this.bitstream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the current update for this field in the object updates service
|
||||
*/
|
||||
undo(): void {
|
||||
this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, this.bitstream.uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to remove this field
|
||||
*/
|
||||
canRemove(): boolean {
|
||||
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to cancel the update to this field
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.fieldUpdate.changeType >= 0;
|
||||
}
|
||||
|
||||
}
|
@@ -19,6 +19,7 @@ import {TranslateService} from '@ngx-translate/core';
|
||||
import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service';
|
||||
import {RelationshipService} from '../../../core/data/relationship.service';
|
||||
import {EntityTypeService} from '../../../core/data/entity-type.service';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-delete',
|
||||
@@ -313,8 +314,8 @@ export class ItemDeleteComponent
|
||||
),
|
||||
).subscribe((types) => {
|
||||
this.itemDataService.delete(this.item.id, types).pipe(first()).subscribe(
|
||||
(succeeded: boolean) => {
|
||||
this.notify(succeeded);
|
||||
(response: RestResponse) => {
|
||||
this.notify(response.isSuccessful);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@@ -37,14 +37,14 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
metadataFields$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
protected itemService: ItemDataService,
|
||||
protected objectUpdatesService: ObjectUpdatesService,
|
||||
protected router: Router,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||
protected route: ActivatedRoute,
|
||||
protected metadataFieldService: RegistryService,
|
||||
public itemService: ItemDataService,
|
||||
public objectUpdatesService: ObjectUpdatesService,
|
||||
public router: Router,
|
||||
public notificationsService: NotificationsService,
|
||||
public translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||
public route: ActivatedRoute,
|
||||
public metadataFieldService: RegistryService,
|
||||
) {
|
||||
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
* Initialize the values and updates of the current item's metadata fields
|
||||
*/
|
||||
public initializeUpdates(): void {
|
||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
|
||||
}
|
||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the prefix for notification messages
|
||||
@@ -83,7 +83,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
* Sends all initial values of this item to the object updates service
|
||||
*/
|
||||
public initializeOriginalFields() {
|
||||
this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified);
|
||||
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -49,18 +49,18 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
|
||||
entityType$: Observable<ItemType>;
|
||||
|
||||
constructor(
|
||||
protected itemService: ItemDataService,
|
||||
protected objectUpdatesService: ObjectUpdatesService,
|
||||
protected router: Router,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||
protected route: ActivatedRoute,
|
||||
protected relationshipService: RelationshipService,
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected requestService: RequestService,
|
||||
protected entityTypeService: EntityTypeService,
|
||||
protected cdr: ChangeDetectorRef,
|
||||
public itemService: ItemDataService,
|
||||
public objectUpdatesService: ObjectUpdatesService,
|
||||
public router: Router,
|
||||
public notificationsService: NotificationsService,
|
||||
public translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||
public route: ActivatedRoute,
|
||||
public relationshipService: RelationshipService,
|
||||
public objectCache: ObjectCacheService,
|
||||
public requestService: RequestService,
|
||||
public entityTypeService: EntityTypeService,
|
||||
public cdr: ChangeDetectorRef,
|
||||
) {
|
||||
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
|
||||
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
|
||||
import { LinkService } from '../core/cache/builders/link.service';
|
||||
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||
|
||||
export function getItemPageRoute(itemId: string) {
|
||||
return new URLCombiner(getItemModulePath(), itemId).toString();
|
||||
@@ -20,6 +21,7 @@ export function getItemEditPath(id: string) {
|
||||
}
|
||||
|
||||
const ITEM_EDIT_PATH = 'edit';
|
||||
const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -45,6 +47,11 @@ const ITEM_EDIT_PATH = 'edit';
|
||||
path: ITEM_EDIT_PATH,
|
||||
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
},
|
||||
{
|
||||
path: UPLOAD_BITSTREAM_PATH,
|
||||
component: UploadBitstreamComponent,
|
||||
canActivate: [AuthenticatedGuard]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
@@ -26,6 +26,7 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent
|
||||
import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component';
|
||||
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component';
|
||||
import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component';
|
||||
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||
import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
|
||||
import { StatisticsModule } from '../statistics/statistics.module';
|
||||
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
|
||||
@@ -58,6 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
|
||||
GenericItemPageFieldComponent,
|
||||
MetadataRepresentationListComponent,
|
||||
RelatedEntitiesSearchComponent,
|
||||
UploadBitstreamComponent,
|
||||
TabbedRelatedEntitiesSearchComponent,
|
||||
AbstractIncrementalListComponent,
|
||||
],
|
||||
|
@@ -28,6 +28,10 @@ const COMMUNITY_MODULE_PATH = 'communities';
|
||||
export function getCommunityModulePath() {
|
||||
return `/${COMMUNITY_MODULE_PATH}`;
|
||||
}
|
||||
const BITSTREAM_MODULE_PATH = 'bitstreams';
|
||||
export function getBitstreamModulePath() {
|
||||
return `/${BITSTREAM_MODULE_PATH}`;
|
||||
}
|
||||
|
||||
const ADMIN_MODULE_PATH = 'admin';
|
||||
|
||||
@@ -63,6 +67,7 @@ export function getDSOPath(dso: DSpaceObject): string {
|
||||
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
||||
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
|
||||
{
|
||||
path: 'mydspace',
|
||||
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
||||
|
@@ -4,6 +4,10 @@ import {
|
||||
ePeopleRegistryReducer,
|
||||
EPeopleRegistryState
|
||||
} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers';
|
||||
import {
|
||||
groupRegistryReducer,
|
||||
GroupRegistryState
|
||||
} from './+admin/admin-access-control/group-registry/group-registry.reducers';
|
||||
import {
|
||||
metadataRegistryReducer,
|
||||
MetadataRegistryState
|
||||
@@ -47,6 +51,7 @@ export interface AppState {
|
||||
relationshipLists: NameVariantListsState;
|
||||
communityList: CommunityListState;
|
||||
epeopleRegistry: EPeopleRegistryState;
|
||||
groupRegistry: GroupRegistryState;
|
||||
}
|
||||
|
||||
export const appReducers: ActionReducerMap<AppState> = {
|
||||
@@ -66,6 +71,7 @@ export const appReducers: ActionReducerMap<AppState> = {
|
||||
relationshipLists: nameVariantReducer,
|
||||
communityList: CommunityListReducer,
|
||||
epeopleRegistry: ePeopleRegistryReducer,
|
||||
groupRegistry: groupRegistryReducer,
|
||||
};
|
||||
|
||||
export const routerStateSelector = (state: AppState) => state.router;
|
||||
|
@@ -2,7 +2,6 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { coreSelector } from '../core.selectors';
|
||||
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
|
||||
import {
|
||||
AddToSSBAction,
|
||||
CommitSSBAction,
|
||||
@@ -16,10 +15,9 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s
|
||||
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
|
||||
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { PatchRequest, PutRequest } from '../data/request.models';
|
||||
import { PatchRequest } from '../data/request.models';
|
||||
import { ObjectCacheService } from './object-cache.service';
|
||||
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
|
||||
import { GenericConstructor } from '../shared/generic-constructor';
|
||||
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RestRequestMethod } from '../data/rest-request-method';
|
||||
|
@@ -69,6 +69,8 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi
|
||||
const actionEntry = action.payload as ServerSyncBufferEntry;
|
||||
if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) {
|
||||
return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) });
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
@@ -135,10 +135,14 @@ import { PoolTask } from './tasks/models/pool-task-object.model';
|
||||
import { TaskObject } from './tasks/models/task-object.model';
|
||||
import { PoolTaskDataService } from './tasks/pool-task-data.service';
|
||||
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
|
||||
import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service';
|
||||
import { BitstreamDataService } from './data/bitstream-data.service';
|
||||
import { VersionDataService } from './data/version-data.service';
|
||||
import { VersionHistoryDataService } from './data/version-history-data.service';
|
||||
import { Version } from './shared/version.model';
|
||||
import { VersionHistory } from './shared/version-history.model';
|
||||
import { WorkflowActionDataService } from './data/workflow-action-data.service';
|
||||
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
|
||||
|
||||
/**
|
||||
* When not in production, endpoint responses can be mocked for testing purposes
|
||||
@@ -233,6 +237,7 @@ const PROVIDERS = [
|
||||
DSpaceObjectDataService,
|
||||
DSOChangeAnalyzer,
|
||||
DefaultChangeAnalyzer,
|
||||
ArrayMoveChangeAnalyzer,
|
||||
ObjectSelectService,
|
||||
CSSVariableService,
|
||||
MenuService,
|
||||
@@ -244,6 +249,7 @@ const PROVIDERS = [
|
||||
TaskResponseParsingService,
|
||||
ClaimedTaskDataService,
|
||||
PoolTaskDataService,
|
||||
BitstreamDataService,
|
||||
EntityTypeService,
|
||||
ContentSourceResponseParsingService,
|
||||
SearchService,
|
||||
@@ -259,6 +265,7 @@ const PROVIDERS = [
|
||||
VersionHistoryDataService,
|
||||
LicenseDataService,
|
||||
ItemTypeDataService,
|
||||
WorkflowActionDataService,
|
||||
// register AuthInterceptor as HttpInterceptor
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
@@ -308,7 +315,8 @@ export const models =
|
||||
ExternalSource,
|
||||
ExternalSourceEntry,
|
||||
Version,
|
||||
VersionHistory
|
||||
VersionHistory,
|
||||
WorkflowAction
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
107
src/app/core/data/array-move-change-analyzer.service.spec.ts
Normal file
107
src/app/core/data/array-move-change-analyzer.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service';
|
||||
import { moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
|
||||
/**
|
||||
* Helper class for creating move tests
|
||||
* Define a "from" and "to" index to move objects within the array before comparing
|
||||
*/
|
||||
class MoveTest {
|
||||
from: number;
|
||||
to: number;
|
||||
|
||||
constructor(from: number, to: number) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ArrayMoveChangeAnalyzer', () => {
|
||||
const comparator = new ArrayMoveChangeAnalyzer<string>();
|
||||
|
||||
let originalArray = [];
|
||||
|
||||
describe('when all values are defined', () => {
|
||||
beforeEach(() => {
|
||||
originalArray = [
|
||||
'98700118-d65d-4636-b1d0-dba83fc932e1',
|
||||
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
|
||||
'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1',
|
||||
'0f608168-cdfc-46b0-92ce-889f7d3ac684',
|
||||
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
|
||||
];
|
||||
});
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/2', path: '/4' },
|
||||
], new MoveTest(2, 4));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/0', path: '/3' },
|
||||
], new MoveTest(0, 3));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/0', path: '/3' },
|
||||
{ op: 'move', from: '/2', path: '/1' }
|
||||
], new MoveTest(0, 3), new MoveTest(1, 2));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/0', path: '/1' },
|
||||
{ op: 'move', from: '/3', path: '/4' }
|
||||
], new MoveTest(0, 1), new MoveTest(3, 4));
|
||||
|
||||
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/0', path: '/3' },
|
||||
{ op: 'move', from: '/2', path: '/1' }
|
||||
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
|
||||
});
|
||||
|
||||
describe('when some values are undefined (index 2 and 3)', () => {
|
||||
beforeEach(() => {
|
||||
originalArray = [
|
||||
'98700118-d65d-4636-b1d0-dba83fc932e1',
|
||||
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
|
||||
undefined,
|
||||
undefined,
|
||||
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
|
||||
];
|
||||
});
|
||||
|
||||
// It can't create a move operation for undefined values, so it should create move operations for the defined values instead
|
||||
testMove([
|
||||
{ op: 'move', from: '/4', path: '/3' },
|
||||
], new MoveTest(2, 4));
|
||||
|
||||
// Moving a defined value should result in the same operations
|
||||
testMove([
|
||||
{ op: 'move', from: '/0', path: '/3' },
|
||||
], new MoveTest(0, 3));
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function for creating a move test
|
||||
*
|
||||
* @param expectedOperations An array of expected operations after comparing the original array with the array
|
||||
* created using the provided MoveTests
|
||||
* @param moves An array of MoveTest objects telling the test where to move objects before comparing
|
||||
*/
|
||||
function testMove(expectedOperations: Operation[], ...moves: MoveTest[]) {
|
||||
describe(`move ${moves.map((move) => `${move.from} to ${move.to}`).join(' and ')}`, () => {
|
||||
let result;
|
||||
|
||||
beforeEach(() => {
|
||||
const movedArray = [...originalArray];
|
||||
moves.forEach((move) => {
|
||||
moveItemInArray(movedArray, move.from, move.to);
|
||||
});
|
||||
result = comparator.diff(originalArray, movedArray);
|
||||
});
|
||||
|
||||
it('should create the expected move operations', () => {
|
||||
expect(result).toEqual(expectedOperations);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
37
src/app/core/data/array-move-change-analyzer.service.ts
Normal file
37
src/app/core/data/array-move-change-analyzer.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* A class to determine move operations between two arrays
|
||||
*/
|
||||
@Injectable()
|
||||
export class ArrayMoveChangeAnalyzer<T> {
|
||||
|
||||
/**
|
||||
* Compare two arrays detecting and returning move operations
|
||||
*
|
||||
* @param array1 The original array
|
||||
* @param array2 The custom array to compare with the original
|
||||
*/
|
||||
diff(array1: T[], array2: T[]): MoveOperation[] {
|
||||
const result = [];
|
||||
const moved = [...array1];
|
||||
array1.forEach((value: T, index: number) => {
|
||||
if (hasValue(value)) {
|
||||
const otherIndex = array2.indexOf(value);
|
||||
const movedIndex = moved.indexOf(value);
|
||||
if (index !== otherIndex && movedIndex !== otherIndex) {
|
||||
moveItemInArray(moved, movedIndex, otherIndex);
|
||||
result.push(Object.assign({
|
||||
op: 'move',
|
||||
from: '/' + movedIndex,
|
||||
path: '/' + otherIndex
|
||||
}) as MoveOperation)
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
58
src/app/core/data/bitstream-data.service.spec.ts
Normal file
58
src/app/core/data/bitstream-data.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BitstreamDataService } from './bitstream-data.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { RequestService } from './request.service';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
|
||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { BitstreamFormatDataService } from './bitstream-format-data.service';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||
import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level';
|
||||
import { PutRequest } from './request.models';
|
||||
|
||||
describe('BitstreamDataService', () => {
|
||||
let service: BitstreamDataService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let requestService: RequestService;
|
||||
let halService: HALEndpointService;
|
||||
let bitstreamFormatService: BitstreamFormatDataService;
|
||||
const bitstreamFormatHref = 'rest-api/bitstreamformats';
|
||||
|
||||
const bitstream = Object.assign(new Bitstream(), {
|
||||
uuid: 'fake-bitstream',
|
||||
_links: {
|
||||
self: { href: 'fake-bitstream-self' }
|
||||
}
|
||||
});
|
||||
const format = Object.assign(new BitstreamFormat(), {
|
||||
id: '2',
|
||||
shortDescription: 'PNG',
|
||||
description: 'Portable Network Graphics',
|
||||
supportLevel: BitstreamFormatSupportLevel.Known
|
||||
});
|
||||
const url = 'fake-bitstream-url';
|
||||
|
||||
beforeEach(() => {
|
||||
objectCache = jasmine.createSpyObj('objectCache', {
|
||||
remove: jasmine.createSpy('remove')
|
||||
});
|
||||
requestService = getMockRequestService();
|
||||
halService = Object.assign(new HALEndpointServiceStub(url));
|
||||
bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', {
|
||||
getBrowseEndpoint: observableOf(bitstreamFormatHref)
|
||||
});
|
||||
|
||||
service = new BitstreamDataService(requestService, null, null, null, objectCache, halService, null, null, null, null, bitstreamFormatService);
|
||||
});
|
||||
|
||||
describe('when updating the bitstream\'s format', () => {
|
||||
beforeEach(() => {
|
||||
service.updateFormat(bitstream, format);
|
||||
});
|
||||
|
||||
it('should configure a put request', () => {
|
||||
expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest));
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,8 +1,8 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
@@ -22,8 +22,14 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { PaginatedList } from './paginated-list';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { RemoteDataError } from './remote-data-error';
|
||||
import { FindListOptions } from './request.models';
|
||||
import { FindListOptions, PutRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { BitstreamFormatDataService } from './bitstream-format-data.service';
|
||||
import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||
import { configureRequest, getResponseFromEntry } from '../shared/operators';
|
||||
import { combineLatest as observableCombineLatest } from 'rxjs';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bitstream}s from the REST API
|
||||
@@ -50,6 +56,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer<Bitstream>,
|
||||
protected bundleService: BundleDataService,
|
||||
protected bitstreamFormatService: BitstreamFormatDataService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -167,4 +174,37 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the format of a bitstream
|
||||
* @param bitstream
|
||||
* @param format
|
||||
*/
|
||||
updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable<RestResponse> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const bitstreamHref$ = this.getBrowseEndpoint().pipe(
|
||||
map((href: string) => `${href}/${bitstream.id}`),
|
||||
switchMap((href: string) => this.halService.getEndpoint('format', href))
|
||||
);
|
||||
const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe(
|
||||
map((href: string) => `${href}/${format.id}`)
|
||||
);
|
||||
observableCombineLatest([bitstreamHref$, formatHref$]).pipe(
|
||||
map(([bitstreamHref, formatHref]) => {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.append('Content-Type', 'text/uri-list');
|
||||
options.headers = headers;
|
||||
return new PutRequest(requestId, bitstreamHref, formatHref, options);
|
||||
}),
|
||||
configureRequest(this.requestService),
|
||||
take(1)
|
||||
).subscribe(() => {
|
||||
this.requestService.removeByHrefSubstring(bitstream.self + '/format');
|
||||
});
|
||||
|
||||
return this.requestService.getByUUID(requestId).pipe(
|
||||
getResponseFromEntry()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -155,9 +155,9 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
|
||||
/**
|
||||
* Delete an existing DSpace Object on the server
|
||||
* @param formatID The DSpace Object'id to be removed
|
||||
* Return an observable that emits true when the deletion was successful, false when it failed
|
||||
* @return the RestResponse as an Observable
|
||||
*/
|
||||
delete(formatID: string): Observable<boolean> {
|
||||
delete(formatID: string): Observable<RestResponse> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||
@@ -173,7 +173,7 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
|
||||
|
||||
return this.requestService.getByUUID(requestId).pipe(
|
||||
find((request: RequestEntry) => request.completed),
|
||||
map((request: RequestEntry) => request.response.isSuccessful)
|
||||
map((request: RequestEntry) => request.response)
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
@@ -18,8 +18,10 @@ import { DataService } from './data.service';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
import { PaginatedList } from './paginated-list';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { FindListOptions } from './request.models';
|
||||
import { FindListOptions, GetRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bundle}s from the REST API
|
||||
@@ -30,6 +32,7 @@ import { RequestService } from './request.service';
|
||||
@dataService(BUNDLE)
|
||||
export class BundleDataService extends DataService<Bundle> {
|
||||
protected linkPath = 'bundles';
|
||||
protected bitstreamsEndpoint = 'bitstreams';
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
@@ -81,4 +84,34 @@ export class BundleDataService extends DataService<Bundle> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bitstreams endpoint for a bundle
|
||||
* @param bundleId
|
||||
*/
|
||||
getBitstreamsEndpoint(bundleId: string): Observable<string> {
|
||||
return this.getBrowseEndpoint().pipe(
|
||||
switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bundle's bitstreams using paginated search options
|
||||
* @param bundleId The bundle's ID
|
||||
* @param searchOptions The search options to use
|
||||
* @param linksToFollow The {@link FollowLinkConfig}s for the request
|
||||
*/
|
||||
getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
|
||||
const hrefObs = this.getBitstreamsEndpoint(bundleId).pipe(
|
||||
map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
|
||||
);
|
||||
hrefObs.pipe(
|
||||
take(1)
|
||||
).subscribe((href) => {
|
||||
const request = new GetRequest(this.requestService.generateRequestId(), href);
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildList<Bitstream>(hrefObs, ...linksToFollow);
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { compare, Operation } from 'fast-json-patch';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import * as uuidv4 from 'uuid/v4';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
|
@@ -511,9 +511,9 @@ export abstract class DataService<T extends CacheableObject> {
|
||||
* @param dsoID The DSpace Object' id to be removed
|
||||
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
|
||||
* metadata should be saved as real metadata
|
||||
* @return an observable that emits true when the deletion was successful, false when it failed
|
||||
* @return the RestResponse as an Observable
|
||||
*/
|
||||
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
|
||||
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||
@@ -539,7 +539,7 @@ export abstract class DataService<T extends CacheableObject> {
|
||||
|
||||
return this.requestService.getByUUID(requestId).pipe(
|
||||
find((request: RequestEntry) => request.completed),
|
||||
map((request: RequestEntry) => request.response.isSuccessful)
|
||||
map((request: RequestEntry) => request.response)
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -47,6 +47,9 @@ describe('ItemDataService', () => {
|
||||
return cold('a', { a: itemEndpoint });
|
||||
}
|
||||
} as HALEndpointService;
|
||||
const bundleService = jasmine.createSpyObj('bundleService', {
|
||||
findByHref: {}
|
||||
});
|
||||
|
||||
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
|
||||
const options = Object.assign(new FindListOptions(), {
|
||||
@@ -87,7 +90,8 @@ describe('ItemDataService', () => {
|
||||
halEndpointService,
|
||||
notificationsService,
|
||||
http,
|
||||
comparator
|
||||
comparator,
|
||||
bundleService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,4 +216,20 @@ describe('ItemDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBundle', () => {
|
||||
const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429';
|
||||
const bundleName = 'ORIGINAL';
|
||||
let result;
|
||||
|
||||
beforeEach(() => {
|
||||
service = initTestService();
|
||||
spyOn(requestService, 'configure');
|
||||
result = service.createBundle(itemId, bundleName);
|
||||
});
|
||||
|
||||
it('should configure a POST request', () => {
|
||||
result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest)));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, find, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { BrowseService } from '../browse/browse.service';
|
||||
@@ -32,6 +32,7 @@ import { RemoteData } from './remote-data';
|
||||
import {
|
||||
DeleteRequest,
|
||||
FindListOptions,
|
||||
GetRequest,
|
||||
MappedCollectionsRequest,
|
||||
PatchRequest,
|
||||
PostRequest,
|
||||
@@ -40,6 +41,10 @@ import {
|
||||
} from './request.models';
|
||||
import { RequestEntry } from './request.reducer';
|
||||
import { RequestService } from './request.service';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { Bundle } from '../shared/bundle.model';
|
||||
import { MetadataMap } from '../shared/metadata.models';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
|
||||
@Injectable()
|
||||
@dataService(ITEM)
|
||||
@@ -56,6 +61,7 @@ export class ItemDataService extends DataService<Item> {
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer<Item>,
|
||||
protected bundleService: BundleDataService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -219,6 +225,76 @@ export class ItemDataService extends DataService<Item> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the endpoint for an item's bundles
|
||||
* @param itemId
|
||||
*/
|
||||
public getBundlesEndpoint(itemId: string): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||
switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item's bundles using paginated search options
|
||||
* @param itemId The item's ID
|
||||
* @param searchOptions The search options to use
|
||||
*/
|
||||
public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<Bundle>>> {
|
||||
const hrefObs = this.getBundlesEndpoint(itemId).pipe(
|
||||
map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
|
||||
);
|
||||
hrefObs.pipe(
|
||||
take(1)
|
||||
).subscribe((href) => {
|
||||
const request = new GetRequest(this.requestService.generateRequestId(), href);
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildList<Bundle>(hrefObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bundle on an item
|
||||
* @param itemId The item's ID
|
||||
* @param bundleName The new bundle's name
|
||||
* @param metadata Optional metadata for the bundle
|
||||
*/
|
||||
public createBundle(itemId: string, bundleName: string, metadata?: MetadataMap): Observable<RemoteData<Bundle>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const hrefObs = this.getBundlesEndpoint(itemId);
|
||||
|
||||
const bundleJson = {
|
||||
name: bundleName,
|
||||
metadata: metadata ? metadata : {}
|
||||
};
|
||||
|
||||
hrefObs.pipe(
|
||||
take(1)
|
||||
).subscribe((href) => {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.append('Content-Type', 'application/json');
|
||||
options.headers = headers;
|
||||
const request = new PostRequest(requestId, href, JSON.stringify(bundleJson), options);
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
const selfLink$ = this.requestService.getByUUID(requestId).pipe(
|
||||
getResponseFromEntry(),
|
||||
map((response: any) => {
|
||||
if (isNotEmpty(response.resourceSelfLinks)) {
|
||||
return response.resourceSelfLinks[0];
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged()
|
||||
) as Observable<string>;
|
||||
|
||||
return selfLink$.pipe(
|
||||
switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the endpoint to move the item
|
||||
* @param itemId
|
||||
|
@@ -8,6 +8,7 @@ import {INotification} from '../../../shared/notifications/models/notification.m
|
||||
*/
|
||||
export const ObjectUpdatesActionTypes = {
|
||||
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
|
||||
ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'),
|
||||
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
|
||||
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
|
||||
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
|
||||
@@ -15,7 +16,9 @@ export const ObjectUpdatesActionTypes = {
|
||||
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
|
||||
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
|
||||
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
|
||||
REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'),
|
||||
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
|
||||
MOVE: type('dspace/core/cache/object-updates/MOVE'),
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@@ -26,7 +29,8 @@ export const ObjectUpdatesActionTypes = {
|
||||
export enum FieldChangeType {
|
||||
UPDATE = 0,
|
||||
ADD = 1,
|
||||
REMOVE = 2
|
||||
REMOVE = 2,
|
||||
MOVE = 3
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +41,10 @@ export class InitializeFieldsAction implements Action {
|
||||
payload: {
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
lastModified: Date,
|
||||
order: string[],
|
||||
pageSize: number,
|
||||
page: number
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -47,13 +54,49 @@ export class InitializeFieldsAction implements Action {
|
||||
* the unique url of the page for which the fields are being initialized
|
||||
* @param fields The identifiable fields of which the updates are kept track of
|
||||
* @param lastModified The last modified date of the object that belongs to the page
|
||||
* @param order A custom order to keep track of objects moving around
|
||||
* @param pageSize The page size used to fill empty pages for the custom order
|
||||
* @param page The first page to populate in the custom order
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
lastModified: Date,
|
||||
order: string[] = [],
|
||||
pageSize: number = 9999,
|
||||
page: number = 0
|
||||
) {
|
||||
this.payload = { url, fields, lastModified };
|
||||
this.payload = { url, fields, lastModified, order, pageSize, page };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to initialize a new page's fields in the ObjectUpdates state
|
||||
*/
|
||||
export class AddPageToCustomOrderAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER;
|
||||
payload: {
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
order: string[],
|
||||
page: number
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new AddPageToCustomOrderAction
|
||||
*
|
||||
* @param url The unique url of the page for which the fields are being added
|
||||
* @param fields The identifiable fields of which the updates are kept track of
|
||||
* @param order A custom order to keep track of objects moving around
|
||||
* @param page The page to populate in the custom order
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
order: string[] = [],
|
||||
page: number = 0
|
||||
) {
|
||||
this.payload = { url, fields, order, page };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +223,8 @@ export class DiscardObjectUpdatesAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.DISCARD;
|
||||
payload: {
|
||||
url: string,
|
||||
notification: INotification
|
||||
notification: INotification,
|
||||
discardAll: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -189,12 +233,14 @@ export class DiscardObjectUpdatesAction implements Action {
|
||||
* @param url
|
||||
* the unique url of the page for which the changes should be discarded
|
||||
* @param notification The notification that is raised when changes are discarded
|
||||
* @param discardAll discard all
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
notification: INotification
|
||||
notification: INotification,
|
||||
discardAll = false
|
||||
) {
|
||||
this.payload = { url, notification };
|
||||
this.payload = { url, notification, discardAll };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +288,13 @@ export class RemoveObjectUpdatesAction implements Action {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove all previously discarded updates in the ObjectUpdates state
|
||||
*/
|
||||
export class RemoveAllObjectUpdatesAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.REMOVE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
||||
*/
|
||||
@@ -267,6 +320,43 @@ export class RemoveFieldUpdateAction implements Action {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
||||
*/
|
||||
export class MoveFieldUpdateAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.MOVE;
|
||||
payload: {
|
||||
url: string,
|
||||
from: number,
|
||||
to: number,
|
||||
fromPage: number,
|
||||
toPage: number,
|
||||
field?: Identifiable
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new RemoveObjectUpdatesAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which a field's change should be removed
|
||||
* @param from The index of the object to move
|
||||
* @param to The index to move the object to
|
||||
* @param fromPage The page to move the object from
|
||||
* @param toPage The page to move the object to
|
||||
* @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages)
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
from: number,
|
||||
to: number,
|
||||
fromPage: number,
|
||||
toPage: number,
|
||||
field?: Identifiable
|
||||
) {
|
||||
this.payload = { url, from, to, fromPage, toPage, field };
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
@@ -279,6 +369,9 @@ export type ObjectUpdatesAction
|
||||
| ReinstateObjectUpdatesAction
|
||||
| RemoveObjectUpdatesAction
|
||||
| RemoveFieldUpdateAction
|
||||
| MoveFieldUpdateAction
|
||||
| AddPageToCustomOrderAction
|
||||
| RemoveAllObjectUpdatesAction
|
||||
| SelectVirtualMetadataAction
|
||||
| SetEditableFieldUpdateAction
|
||||
| SetValidFieldUpdateAction;
|
||||
|
@@ -3,12 +3,12 @@ import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import {
|
||||
DiscardObjectUpdatesAction,
|
||||
ObjectUpdatesAction,
|
||||
ObjectUpdatesActionTypes,
|
||||
ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction,
|
||||
RemoveObjectUpdatesAction
|
||||
} from './object-updates.actions';
|
||||
import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { of as observableOf, race as observableRace, Subject } from 'rxjs';
|
||||
import { hasNoValue } from '../../../shared/empty.util';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NotificationsActionTypes,
|
||||
RemoveNotificationAction
|
||||
} from '../../../shared/notifications/notifications.actions';
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
/**
|
||||
* NGRX effects for ObjectUpdatesActions
|
||||
@@ -53,13 +54,14 @@ export class ObjectUpdatesEffects {
|
||||
.pipe(
|
||||
ofType(...Object.values(ObjectUpdatesActionTypes)),
|
||||
map((action: ObjectUpdatesAction) => {
|
||||
const url: string = action.payload.url;
|
||||
if (hasValue((action as any).payload)) {
|
||||
const url: string = (action as any).payload.url;
|
||||
if (hasNoValue(this.actionMap$[url])) {
|
||||
this.actionMap$[url] = new Subject<ObjectUpdatesAction>();
|
||||
}
|
||||
this.actionMap$[url].next(action);
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -91,9 +93,15 @@ export class ObjectUpdatesEffects {
|
||||
const url: string = action.payload.url;
|
||||
const notification: INotification = action.payload.notification;
|
||||
const timeOut = notification.options.timeOut;
|
||||
|
||||
let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url);
|
||||
if (action.payload.discardAll) {
|
||||
removeAction = new RemoveAllObjectUpdatesAction();
|
||||
}
|
||||
|
||||
return observableRace(
|
||||
// Either wait for the delay and perform a remove action
|
||||
observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)),
|
||||
observableOf(removeAction).pipe(delay(timeOut)),
|
||||
// Or wait for a a user action
|
||||
this.actionMap$[url].pipe(
|
||||
take(1),
|
||||
@@ -106,19 +114,19 @@ export class ObjectUpdatesEffects {
|
||||
return { type: 'NO_ACTION' }
|
||||
}
|
||||
// If someone performed another action, assume the user does not want to reinstate and remove all changes
|
||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
||||
return removeAction
|
||||
})
|
||||
),
|
||||
this.notificationActionMap$[notification.id].pipe(
|
||||
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION),
|
||||
map(() => {
|
||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
||||
return removeAction;
|
||||
})
|
||||
),
|
||||
this.notificationActionMap$[this.allIdentifier].pipe(
|
||||
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS),
|
||||
map(() => {
|
||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
||||
return removeAction;
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import * as deepFreeze from 'deep-freeze';
|
||||
import {
|
||||
AddFieldUpdateAction,
|
||||
AddFieldUpdateAction, AddPageToCustomOrderAction,
|
||||
DiscardObjectUpdatesAction,
|
||||
FieldChangeType,
|
||||
InitializeFieldsAction,
|
||||
ReinstateObjectUpdatesAction,
|
||||
InitializeFieldsAction, MoveFieldUpdateAction,
|
||||
ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction,
|
||||
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction,
|
||||
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
||||
} from './object-updates.actions';
|
||||
@@ -85,6 +85,16 @@ describe('objectUpdatesReducer', () => {
|
||||
virtualMetadataSources: {
|
||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||
},
|
||||
customOrder: {
|
||||
initialOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
newOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
pageSize: 10,
|
||||
changed: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,6 +121,16 @@ describe('objectUpdatesReducer', () => {
|
||||
virtualMetadataSources: {
|
||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||
},
|
||||
customOrder: {
|
||||
initialOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
newOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
pageSize: 10,
|
||||
changed: false
|
||||
}
|
||||
},
|
||||
[url + OBJECT_UPDATES_TRASH_PATH]: {
|
||||
fieldStates: {
|
||||
@@ -145,6 +165,16 @@ describe('objectUpdatesReducer', () => {
|
||||
virtualMetadataSources: {
|
||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||
},
|
||||
customOrder: {
|
||||
initialOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
newOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||
],
|
||||
pageSize: 10,
|
||||
changed: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,7 +243,7 @@ describe('objectUpdatesReducer', () => {
|
||||
});
|
||||
|
||||
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
|
||||
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate);
|
||||
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0);
|
||||
|
||||
const expectedState = {
|
||||
[url]: {
|
||||
@@ -231,7 +261,17 @@ describe('objectUpdatesReducer', () => {
|
||||
},
|
||||
fieldUpdates: {},
|
||||
virtualMetadataSources: {},
|
||||
lastModified: modDate
|
||||
lastModified: modDate,
|
||||
customOrder: {
|
||||
initialOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable3.uuid] }
|
||||
],
|
||||
newOrderPages: [
|
||||
{ order: [identifiable1.uuid, identifiable3.uuid] }
|
||||
],
|
||||
pageSize: 10,
|
||||
changed: false
|
||||
}
|
||||
}
|
||||
};
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
@@ -283,10 +323,44 @@ describe('objectUpdatesReducer', () => {
|
||||
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove all updates from the state when the REMOVE_ALL action is dispatched', () => {
|
||||
const action = new RemoveAllObjectUpdatesAction();
|
||||
|
||||
const newState = objectUpdatesReducer(discardedTestState, action as any);
|
||||
expect(newState[url].fieldUpdates).toBeUndefined();
|
||||
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => {
|
||||
const action = new RemoveFieldUpdateAction(url, uuid);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldUpdates[uuid]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should move the custom order from the state when the MOVE action is dispatched', () => {
|
||||
const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]);
|
||||
expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]);
|
||||
expect(newState[url].customOrder.changed).toEqual(true);
|
||||
});
|
||||
|
||||
it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => {
|
||||
const identifiable4 = {
|
||||
uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955',
|
||||
key: 'dc.description.abstract',
|
||||
language: null,
|
||||
value: 'Extra value'
|
||||
};
|
||||
const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
// Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values
|
||||
expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10);
|
||||
expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined();
|
||||
// Verify the new page is correct
|
||||
expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid);
|
||||
});
|
||||
});
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user