feat(icons): Made icons for libraries and shelves optional with no default icons. (#2599)

* feat(icons): Made icons for libraries and shelves optional with no default icon displayed.

* Added tests for making sure the API handles nullable icons for Shelves and Libraries.

* Fixed some issues identified during PR review.

* Rebased on develop.
This commit is contained in:
Giroux Arthur
2026-02-07 08:25:34 -07:00
committed by GitHub
parent 07cbf89e4c
commit 14236299f2
28 changed files with 817 additions and 90 deletions

View File

@@ -15,7 +15,6 @@ public class MagicShelf {
@Size(max = 255, message = "Shelf name must not exceed 255 characters")
private String name;
@NotBlank(message = "Icon must not be blank")
@Size(max = 64, message = "Icon must not exceed 64 characters")
private String icon;

View File

@@ -19,10 +19,7 @@ public class CreateLibraryRequest {
@NotBlank(message = "Library name must not be empty.")
private String name;
@NotBlank(message = "Library icon must not be empty.")
private String icon;
@NotNull(message = "Library icon type must not be null.")
private IconType iconType;
@NotEmpty(message = "Library paths must not be empty.")

View File

@@ -16,10 +16,7 @@ public class ShelfCreateRequest {
@NotBlank(message = "Shelf name must not be empty.")
private String name;
@NotBlank(message = "Shelf icon must not be empty.")
private String icon;
@NotNull(message = "Shelf icon type must not be null.")
private IconType iconType;
private boolean publicShelf;

View File

@@ -44,9 +44,8 @@ public class LibraryEntity {
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@Column(name = "icon_type")
private IconType iconType;
@Column(name = "file_naming_pattern")
private String fileNamingPattern;
@@ -65,10 +64,4 @@ public class LibraryEntity {
@Builder.Default
private LibraryOrganizationMode organizationMode = LibraryOrganizationMode.AUTO_DETECT;
@PrePersist
public void ensureIconType() {
if (this.iconType == null) {
this.iconType = IconType.PRIME_NG;
}
}
}

View File

@@ -27,13 +27,11 @@ public class MagicShelfEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@Column(name = "icon_type")
private IconType iconType;
@Column(name = "filter_json", columnDefinition = "json", nullable = false)
private String filterJson;
@@ -54,11 +52,4 @@ public class MagicShelfEntity {
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
@PrePersist
public void ensureIconType() {
if (this.iconType == null) {
this.iconType = IconType.PRIME_NG;
}
}
}

View File

@@ -36,9 +36,8 @@ public class ShelfEntity {
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@Column(name = "icon_type")
private IconType iconType;
@Column(name = "is_public", nullable = false)
@Builder.Default

View File

@@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.booklore.model.enums.IconType;
@Slf4j
@Service
@@ -24,6 +25,7 @@ public class UserDefaultsService {
.user(user)
.name("Favorites")
.icon("heart")
.iconType(IconType.PRIME_NG)
.build();
shelfRepository.save(shelf);
}

View File

@@ -0,0 +1,8 @@
ALTER TABLE library MODIFY COLUMN icon VARCHAR(64) NULL;
ALTER TABLE library MODIFY COLUMN icon_type VARCHAR(255) NULL;
ALTER TABLE magic_shelf MODIFY COLUMN icon VARCHAR(64) NULL;
ALTER TABLE magic_shelf MODIFY COLUMN icon_type VARCHAR(255) NULL;
ALTER TABLE shelf MODIFY COLUMN icon VARCHAR(64) NULL;
ALTER TABLE shelf MODIFY COLUMN icon_type VARCHAR(255) NULL;

View File

@@ -0,0 +1,191 @@
package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.MagicShelf;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.model.enums.IconType;
import org.booklore.repository.MagicShelfRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MagicShelfServiceTest {
@Mock
private MagicShelfRepository magicShelfRepository;
@Mock
private AuthenticationService authenticationService;
@InjectMocks
private MagicShelfService magicShelfService;
private BookLoreUser user;
@BeforeEach
void setUp() {
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
permissions.setAdmin(true);
user = BookLoreUser.builder().id(1L).isDefaultPassword(false).permissions(permissions).build();
}
@Test
void createShelf_withNullIcon_shouldPersistNullIconValues() {
MagicShelf dto = new MagicShelf();
dto.setName("Unread Books");
dto.setIcon(null);
dto.setIconType(null);
dto.setFilterJson("{\"status\": \"unread\"}");
dto.setIsPublic(false);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(magicShelfRepository.existsByUserIdAndName(1L, "Unread Books")).thenReturn(false);
when(magicShelfRepository.save(any(MagicShelfEntity.class))).thenAnswer(invocation -> {
MagicShelfEntity entity = invocation.getArgument(0);
entity.setId(1L);
return entity;
});
MagicShelf result = magicShelfService.createOrUpdateShelf(dto);
ArgumentCaptor<MagicShelfEntity> captor = ArgumentCaptor.forClass(MagicShelfEntity.class);
verify(magicShelfRepository).save(captor.capture());
MagicShelfEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("Unread Books", saved.getName());
}
@Test
void createShelf_withIcon_shouldPersistIconValues() {
MagicShelf dto = new MagicShelf();
dto.setName("Favorites");
dto.setIcon("star");
dto.setIconType(IconType.PRIME_NG);
dto.setFilterJson("{\"rating\": 5}");
dto.setIsPublic(false);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(magicShelfRepository.existsByUserIdAndName(1L, "Favorites")).thenReturn(false);
when(magicShelfRepository.save(any(MagicShelfEntity.class))).thenAnswer(invocation -> {
MagicShelfEntity entity = invocation.getArgument(0);
entity.setId(1L);
return entity;
});
MagicShelf result = magicShelfService.createOrUpdateShelf(dto);
assertNotNull(result);
assertEquals("star", result.getIcon());
assertEquals(IconType.PRIME_NG, result.getIconType());
}
@Test
void updateShelf_withNullIcon_shouldClearIconValues() {
MagicShelfEntity existing = MagicShelfEntity.builder()
.id(1L)
.userId(1L)
.name("Old Shelf")
.icon("star")
.iconType(IconType.PRIME_NG)
.filterJson("{\"status\": \"reading\"}")
.build();
MagicShelf dto = new MagicShelf();
dto.setId(1L);
dto.setName("Updated Shelf");
dto.setIcon(null);
dto.setIconType(null);
dto.setFilterJson("{\"status\": \"updated\"}");
dto.setIsPublic(false);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(magicShelfRepository.findById(1L)).thenReturn(Optional.of(existing));
when(magicShelfRepository.save(any(MagicShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
magicShelfService.createOrUpdateShelf(dto);
ArgumentCaptor<MagicShelfEntity> captor = ArgumentCaptor.forClass(MagicShelfEntity.class);
verify(magicShelfRepository).save(captor.capture());
MagicShelfEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("Updated Shelf", saved.getName());
}
@Test
void updateShelf_withIcon_shouldPreserveIconValues() {
MagicShelfEntity existing = MagicShelfEntity.builder()
.id(1L)
.userId(1L)
.name("Old Shelf")
.icon("star")
.iconType(IconType.PRIME_NG)
.filterJson("{\"status\": \"reading\"}")
.build();
MagicShelf dto = new MagicShelf();
dto.setId(1L);
dto.setName("Updated Shelf");
dto.setIcon("bookmark");
dto.setIconType(IconType.CUSTOM_SVG);
dto.setFilterJson("{\"status\": \"updated\"}");
dto.setIsPublic(false);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(magicShelfRepository.findById(1L)).thenReturn(Optional.of(existing));
when(magicShelfRepository.save(any(MagicShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
magicShelfService.createOrUpdateShelf(dto);
ArgumentCaptor<MagicShelfEntity> captor = ArgumentCaptor.forClass(MagicShelfEntity.class);
verify(magicShelfRepository).save(captor.capture());
MagicShelfEntity saved = captor.getValue();
assertEquals("bookmark", saved.getIcon());
assertEquals(IconType.CUSTOM_SVG, saved.getIconType());
}
@Test
void updateShelf_fromIconToNull_shouldAllowRemovingIcon() {
MagicShelfEntity existing = MagicShelfEntity.builder()
.id(1L)
.userId(1L)
.name("Shelf With Icon")
.icon("heart")
.iconType(IconType.PRIME_NG)
.filterJson("{}")
.build();
MagicShelf dto = new MagicShelf();
dto.setId(1L);
dto.setName("Shelf With Icon");
dto.setIcon(null);
dto.setIconType(null);
dto.setFilterJson("{}");
dto.setIsPublic(false);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(magicShelfRepository.findById(1L)).thenReturn(Optional.of(existing));
when(magicShelfRepository.save(any(MagicShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
MagicShelf result = magicShelfService.createOrUpdateShelf(dto);
assertNull(result.getIcon());
assertNull(result.getIconType());
}
}

View File

@@ -0,0 +1,190 @@
package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.ShelfMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Shelf;
import org.booklore.model.dto.request.ShelfCreateRequest;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.enums.IconType;
import org.booklore.repository.BookRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ShelfServiceTest {
@Mock
private ShelfRepository shelfRepository;
@Mock
private BookRepository bookRepository;
@Mock
private ShelfMapper shelfMapper;
@Mock
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@InjectMocks
private ShelfService shelfService;
private BookLoreUser user;
private BookLoreUserEntity userEntity;
@BeforeEach
void setUp() {
user = BookLoreUser.builder().id(1L).isDefaultPassword(false).build();
userEntity = BookLoreUserEntity.builder().id(1L).username("testuser").build();
}
@Test
void createShelf_withNullIcon_shouldPersistNullIconValues() {
ShelfCreateRequest request = ShelfCreateRequest.builder()
.name("My Shelf")
.icon(null)
.iconType(null)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.existsByUserIdAndName(1L, "My Shelf")).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfMapper.toShelf(any(ShelfEntity.class))).thenReturn(Shelf.builder().name("My Shelf").build());
shelfService.createShelf(request);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("My Shelf", saved.getName());
}
@Test
void createShelf_withIcon_shouldPersistIconValues() {
ShelfCreateRequest request = ShelfCreateRequest.builder()
.name("My Shelf")
.icon("heart")
.iconType(IconType.PRIME_NG)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.existsByUserIdAndName(1L, "My Shelf")).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfMapper.toShelf(any(ShelfEntity.class))).thenReturn(
Shelf.builder().name("My Shelf").icon("heart").iconType(IconType.PRIME_NG).build());
shelfService.createShelf(request);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertEquals("heart", saved.getIcon());
assertEquals(IconType.PRIME_NG, saved.getIconType());
}
@Test
void updateShelf_withNullIcon_shouldClearIconValues() {
ShelfEntity existingShelf = ShelfEntity.builder()
.id(1L)
.name("Old Shelf")
.icon("star")
.iconType(IconType.PRIME_NG)
.user(userEntity)
.build();
ShelfCreateRequest request = ShelfCreateRequest.builder()
.name("Updated Shelf")
.icon(null)
.iconType(null)
.build();
when(shelfRepository.findById(1L)).thenReturn(Optional.of(existingShelf));
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfMapper.toShelf(any(ShelfEntity.class))).thenReturn(Shelf.builder().name("Updated Shelf").build());
shelfService.updateShelf(1L, request);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("Updated Shelf", saved.getName());
}
@Test
void updateShelf_withIcon_shouldPreserveIconValues() {
ShelfEntity existingShelf = ShelfEntity.builder()
.id(1L)
.name("Old Shelf")
.icon("star")
.iconType(IconType.PRIME_NG)
.user(userEntity)
.build();
ShelfCreateRequest request = ShelfCreateRequest.builder()
.name("Updated Shelf")
.icon("bookmark")
.iconType(IconType.CUSTOM_SVG)
.build();
when(shelfRepository.findById(1L)).thenReturn(Optional.of(existingShelf));
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfMapper.toShelf(any(ShelfEntity.class))).thenReturn(
Shelf.builder().name("Updated Shelf").icon("bookmark").iconType(IconType.CUSTOM_SVG).build());
shelfService.updateShelf(1L, request);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertEquals("bookmark", saved.getIcon());
assertEquals(IconType.CUSTOM_SVG, saved.getIconType());
}
@Test
void createShelf_withCustomSvgIcon_shouldPersistCorrectIconType() {
ShelfCreateRequest request = ShelfCreateRequest.builder()
.name("SVG Shelf")
.icon("<svg>...</svg>")
.iconType(IconType.CUSTOM_SVG)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.existsByUserIdAndName(1L, "SVG Shelf")).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfMapper.toShelf(any(ShelfEntity.class))).thenReturn(
Shelf.builder().name("SVG Shelf").icon("<svg>...</svg>").iconType(IconType.CUSTOM_SVG).build());
shelfService.createShelf(request);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertEquals("<svg>...</svg>", saved.getIcon());
assertEquals(IconType.CUSTOM_SVG, saved.getIconType());
}
}

View File

@@ -0,0 +1,236 @@
package org.booklore.service.library;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.LibraryMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Library;
import org.booklore.model.dto.LibraryPath;
import org.booklore.model.dto.request.CreateLibraryRequest;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
import org.booklore.model.enums.IconType;
import org.booklore.repository.LibraryRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.booklore.mapper.BookMapper;
import org.booklore.repository.BookRepository;
import org.booklore.repository.LibraryPathRepository;
import org.booklore.repository.UserRepository;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.service.NotificationService;
import org.booklore.service.monitoring.MonitoringService;
import org.booklore.util.FileService;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LibraryServiceIconTest {
@Mock
private LibraryRepository libraryRepository;
@Mock
private LibraryPathRepository libraryPathRepository;
@Mock
private BookRepository bookRepository;
@Mock
private LibraryProcessingService libraryProcessingService;
@Mock
private BookMapper bookMapper;
@Mock
private LibraryMapper libraryMapper;
@Mock
private NotificationService notificationService;
@Mock
private FileService fileService;
@Mock
private MonitoringService monitoringService;
@Mock
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@InjectMocks
private LibraryService libraryService;
private BookLoreUser user;
private BookLoreUserEntity userEntity;
@BeforeEach
void setUp() {
user = BookLoreUser.builder().id(1L).isDefaultPassword(false).build();
userEntity = BookLoreUserEntity.builder().id(1L).username("testuser").build();
}
@Test
void updateLibrary_withNullIcon_shouldClearIconValues() {
LibraryEntity existing = LibraryEntity.builder()
.id(1L)
.name("My Library")
.icon("book")
.iconType(IconType.PRIME_NG)
.libraryPaths(new ArrayList<>())
.watch(false)
.build();
CreateLibraryRequest request = CreateLibraryRequest.builder()
.name("Updated Library")
.icon(null)
.iconType(null)
.paths(Collections.emptyList())
.watch(false)
.build();
when(libraryRepository.findById(1L)).thenReturn(Optional.of(existing));
when(libraryRepository.save(any(LibraryEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(Library.builder().name("Updated Library").build());
libraryService.updateLibrary(request, 1L);
ArgumentCaptor<LibraryEntity> captor = ArgumentCaptor.forClass(LibraryEntity.class);
verify(libraryRepository).save(captor.capture());
LibraryEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("Updated Library", saved.getName());
}
@Test
void updateLibrary_withIcon_shouldPreserveIconValues() {
LibraryEntity existing = LibraryEntity.builder()
.id(1L)
.name("My Library")
.icon("book")
.iconType(IconType.PRIME_NG)
.libraryPaths(new ArrayList<>())
.watch(false)
.build();
CreateLibraryRequest request = CreateLibraryRequest.builder()
.name("Updated Library")
.icon("folder")
.iconType(IconType.CUSTOM_SVG)
.paths(Collections.emptyList())
.watch(false)
.build();
when(libraryRepository.findById(1L)).thenReturn(Optional.of(existing));
when(libraryRepository.save(any(LibraryEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(
Library.builder().name("Updated Library").icon("folder").iconType(IconType.CUSTOM_SVG).build());
libraryService.updateLibrary(request, 1L);
ArgumentCaptor<LibraryEntity> captor = ArgumentCaptor.forClass(LibraryEntity.class);
verify(libraryRepository).save(captor.capture());
LibraryEntity saved = captor.getValue();
assertEquals("folder", saved.getIcon());
assertEquals(IconType.CUSTOM_SVG, saved.getIconType());
}
@Test
void updateLibrary_fromIconToNull_shouldAllowRemovingIcon() {
LibraryEntity existing = LibraryEntity.builder()
.id(1L)
.name("Library With Icon")
.icon("star")
.iconType(IconType.PRIME_NG)
.libraryPaths(new ArrayList<>())
.watch(false)
.build();
CreateLibraryRequest request = CreateLibraryRequest.builder()
.name("Library With Icon")
.icon(null)
.iconType(null)
.paths(Collections.emptyList())
.watch(false)
.build();
when(libraryRepository.findById(1L)).thenReturn(Optional.of(existing));
when(libraryRepository.save(any(LibraryEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(Library.builder().name("Library With Icon").build());
libraryService.updateLibrary(request, 1L);
ArgumentCaptor<LibraryEntity> captor = ArgumentCaptor.forClass(LibraryEntity.class);
verify(libraryRepository).save(captor.capture());
LibraryEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
}
@Test
void createLibrary_withNullIcon_shouldPersistNullIconValues() {
CreateLibraryRequest request = CreateLibraryRequest.builder()
.name("No Icon Library")
.icon(null)
.iconType(null)
.paths(Collections.emptyList())
.watch(false)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
when(libraryRepository.save(any(LibraryEntity.class))).thenAnswer(invocation -> {
LibraryEntity entity = invocation.getArgument(0);
entity.setId(1L);
return entity;
});
when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(Library.builder().name("No Icon Library").build());
libraryService.createLibrary(request);
ArgumentCaptor<LibraryEntity> captor = ArgumentCaptor.forClass(LibraryEntity.class);
verify(libraryRepository).save(captor.capture());
LibraryEntity saved = captor.getValue();
assertNull(saved.getIcon());
assertNull(saved.getIconType());
assertEquals("No Icon Library", saved.getName());
}
@Test
void createLibrary_withIcon_shouldPersistIconValues() {
CreateLibraryRequest request = CreateLibraryRequest.builder()
.name("Icon Library")
.icon("book")
.iconType(IconType.PRIME_NG)
.paths(Collections.emptyList())
.watch(false)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
when(libraryRepository.save(any(LibraryEntity.class))).thenAnswer(invocation -> {
LibraryEntity entity = invocation.getArgument(0);
entity.setId(1L);
return entity;
});
when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(
Library.builder().name("Icon Library").icon("book").iconType(IconType.PRIME_NG).build());
libraryService.createLibrary(request);
ArgumentCaptor<LibraryEntity> captor = ArgumentCaptor.forClass(LibraryEntity.class);
verify(libraryRepository).save(captor.capture());
LibraryEntity saved = captor.getValue();
assertEquals("book", saved.getIcon());
assertEquals(IconType.PRIME_NG, saved.getIconType());
}
}

View File

@@ -0,0 +1,71 @@
package org.booklore.service.user;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.enums.IconType;
import org.booklore.repository.ShelfRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserDefaultsServiceTest {
@Mock
private ShelfRepository shelfRepository;
@Mock
private ObjectMapper objectMapper;
@Mock
private DefaultUserSettingsProvider defaultSettingsProvider;
@InjectMocks
private UserDefaultsService userDefaultsService;
@Test
void addDefaultShelves_shouldCreateFavoritesShelfWithIcon() {
BookLoreUserEntity user = BookLoreUserEntity.builder()
.id(1L)
.username("testuser")
.build();
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
userDefaultsService.addDefaultShelves(user);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertEquals("Favorites", saved.getName());
assertEquals("heart", saved.getIcon());
assertEquals(IconType.PRIME_NG, saved.getIconType());
assertEquals(user, saved.getUser());
}
@Test
void addDefaultShelves_shouldSetExplicitIconType() {
BookLoreUserEntity user = BookLoreUserEntity.builder()
.id(2L)
.username("anotheruser")
.build();
when(shelfRepository.save(any(ShelfEntity.class))).thenAnswer(invocation -> invocation.getArgument(0));
userDefaultsService.addDefaultShelves(user);
ArgumentCaptor<ShelfEntity> captor = ArgumentCaptor.forClass(ShelfEntity.class);
verify(shelfRepository).save(captor.capture());
ShelfEntity saved = captor.getValue();
assertNotNull(saved.getIconType(), "Default shelf must have an explicit iconType since entity no longer has a default");
assertNotNull(saved.getIcon(), "Default shelf must have an explicit icon since entity no longer has a default");
}
}

View File

@@ -45,17 +45,18 @@
/>
</div>
<label [for]="'shelf-' + shelf.id" class="shelf-label">
<div class="shelf-icon-wrapper">
@if (shelf.icon) {
<div class="shelf-icon-wrapper">
<app-icon-display
[icon]="getShelfIcon(shelf)"
size="20px"
iconClass="shelf-icon"
/>
} @else {
<i class="pi pi-bookmark shelf-icon"></i>
}
</div>
} @else {
<div class="shelf-icon-wrapper-empty">
</div>
}
<span class="shelf-name">{{ shelf.name }}</span>
</label>
</div>

View File

@@ -153,12 +153,15 @@
cursor: pointer;
min-width: 0;
.shelf-icon-wrapper, .shelf-icon-wrapper-empty {
width: 34px;
height: 34px;
}
.shelf-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: var(--overlay-background);
border-radius: 8px;
flex-shrink: 0;
@@ -187,7 +190,7 @@
.shelf-label {
gap: 0.5rem;
.shelf-icon-wrapper {
.shelf-icon-wrapper, .shelf-icon-wrapper-empty {
width: 32px;
height: 32px;

View File

@@ -123,10 +123,10 @@ export class ShelfAssignerComponent implements OnInit {
}
getShelfIcon(shelf: Shelf): IconSelection {
if (shelf.iconType === 'PRIME_NG') {
return {type: 'PRIME_NG', value: `pi pi-${shelf.icon}`};
if (shelf.iconType === 'CUSTOM_SVG') {
return {type: 'CUSTOM_SVG', value: shelf.icon ?? ""};
} else {
return {type: 'CUSTOM_SVG', value: shelf.icon};
return {type: 'PRIME_NG', value: `pi pi-${shelf.icon}`};
}
}
}

View File

@@ -55,8 +55,8 @@ export class ShelfCreatorComponent {
}
createShelf(): void {
const iconValue = this.selectedIcon?.value || 'bookmark';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
const iconValue = this.selectedIcon?.value ?? null;
const iconType = this.selectedIcon?.type ?? null;
const newShelf: Partial<Shelf> = {
name: this.shelfName,

View File

@@ -47,6 +47,7 @@ export class ShelfEditDialogComponent implements OnInit {
if (this.shelf) {
this.shelfName = this.shelf.name;
this.isPublic = this.shelf.publicShelf ?? false;
if (this.shelf.iconType && this.shelf.icon) {
if (this.shelf.iconType === 'PRIME_NG') {
this.selectedIcon = {type: 'PRIME_NG', value: `pi pi-${this.shelf.icon}`};
} else {
@@ -54,6 +55,7 @@ export class ShelfEditDialogComponent implements OnInit {
}
}
}
}
openIconPicker() {
this.iconPickerService.open().subscribe(icon => {
@@ -68,8 +70,8 @@ export class ShelfEditDialogComponent implements OnInit {
}
save() {
const iconValue = this.selectedIcon?.value || 'bookmark';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
const iconValue = this.selectedIcon?.value ?? null;
const iconType = this.selectedIcon?.type ?? null;
const shelf: Shelf = {
name: this.shelfName,

View File

@@ -4,8 +4,8 @@ import {BookType} from './book.model';
export interface Library {
id?: number;
name: string;
icon: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
icon?: string | null;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG' | null;
watch: boolean;
fileNamingPattern?: string;
sort?: SortOption;

View File

@@ -3,8 +3,8 @@ import {SortOption} from './sort.model';
export interface Shelf {
id?: number;
name: string;
icon: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
icon?: string | null;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG' | null;
sort?: SortOption;
publicShelf?: boolean;
userId?: number;

View File

@@ -45,7 +45,7 @@
<div class="form-field">
<label>
Icon <span class="required">*</span>
Icon (Optional)
</label>
@if (!selectedIcon) {
<button class="icon-picker-trigger" (click)="openIconPicker()" type="button">

View File

@@ -70,12 +70,14 @@ export class LibraryCreatorComponent implements OnInit {
this.chosenLibraryName = name;
this.editModeLibraryName = name;
if (icon != null && iconType) {
if (iconType === 'CUSTOM_SVG') {
this.selectedIcon = {type: 'CUSTOM_SVG', value: icon};
} else {
const value = icon.slice(0, 6) === 'pi pi-' ? icon : `pi pi-${icon}`;
this.selectedIcon = {type: 'PRIME_NG', value: value};
}
}
this.watch = watch;
if (formatPriority && formatPriority.length > 0) {
@@ -186,7 +188,7 @@ export class LibraryCreatorComponent implements OnInit {
}
isLibraryDetailsValid(): boolean {
return !!this.chosenLibraryName.trim() && !!this.selectedIcon;
return !!this.chosenLibraryName.trim();
}
isDirectorySelectionValid(): boolean {
@@ -207,8 +209,8 @@ export class LibraryCreatorComponent implements OnInit {
}
}
const iconValue = this.selectedIcon?.value || 'heart';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
const iconValue = this.selectedIcon?.value ?? null;
const iconType = this.selectedIcon?.type ?? null;
const library: Library = {
name: this.chosenLibraryName,

View File

@@ -29,8 +29,9 @@
<div class="icon-picker-section">
<div class="icon-picker-wrapper">
<label>Shelf Icon</label>
<div class="icon-display-container" (click)="openIconPicker()">
@if (selectedIcon) {
<div class="selected-icon-display">
<div class="icon-display-container" (click)="openIconPicker()" (keydown.enter)="openIconPicker()" tabindex="0" role="button">
<app-icon-display
[icon]="selectedIcon"
[size]="'16px'"
@@ -38,13 +39,26 @@
alt="Selected shelf icon">
</app-icon-display>
<span class="icon-label">Click to change</span>
</div>
<p-button
icon="pi pi-times"
severity="danger"
[outlined]="true"
[rounded]="true"
size="small"
(onClick)="clearSelectedIcon()"
pTooltip="Remove icon"
tooltipPosition="left"
/>
</div>
} @else {
<div class="icon-display-container" (click)="openIconPicker()" (keydown.enter)="openIconPicker()" tabindex="0" role="button">
<div class="icon-placeholder">
<i class="pi pi-plus"></i>
<span>Select Icon</span>
</div>
}
</div>
}
</div>
</div>
@if (isAdmin) {

View File

@@ -74,6 +74,12 @@
font-size: 0.9rem;
}
.selected-icon-display {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-display-container {
display: flex;
align-items: center;
@@ -89,6 +95,11 @@
border-color: var(--primary-color);
}
&:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.selected-icon {
flex-shrink: 0;
}

View File

@@ -19,6 +19,7 @@ import {IconPickerService, IconSelection} from '../../../shared/service/icon-pic
import {CheckboxChangeEvent, CheckboxModule} from "primeng/checkbox";
import {UserService} from "../../settings/user-management/user.service";
import {IconDisplayComponent} from '../../../shared/components/icon-display/icon-display.component';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../book/service/book.service';
import {ShelfService} from '../../book/service/shelf.service';
import {Shelf} from '../../book/model/shelf.model';
@@ -167,7 +168,8 @@ const FIELD_CONFIGS: Record<RuleField, FullFieldConfig> = {
MultiSelect,
AutoComplete,
CheckboxModule,
IconDisplayComponent
IconDisplayComponent,
Tooltip
]
})
export class MagicShelfComponent implements OnInit {
@@ -271,7 +273,7 @@ export class MagicShelfComponent implements OnInit {
this.form = new FormGroup({
name: new FormControl<string | null>(data?.name ?? null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(iconValue, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(iconValue),
isPublic: new FormControl<boolean>(data?.isPublic ?? false),
group: data?.filterJson ? this.buildGroupFromData(JSON.parse(data.filterJson)) : this.createGroup()
});
@@ -285,7 +287,7 @@ export class MagicShelfComponent implements OnInit {
} else {
this.form = new FormGroup({
name: new FormControl<string | null>(null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(null),
isPublic: new FormControl<boolean>(false),
group: this.createGroup()
});
@@ -477,6 +479,11 @@ export class MagicShelfComponent implements OnInit {
});
}
clearSelectedIcon(): void {
this.selectedIcon = null;
this.form.get('icon')?.setValue(null);
}
private hasAtLeastOneValidRule(group: GroupFormGroup): boolean {
const rulesArray = group.get('rules') as FormArray;

View File

@@ -11,8 +11,8 @@ import {GroupRule} from '../component/magic-shelf-component';
export interface MagicShelf {
id?: number | null;
name: string;
icon?: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
icon?: string | null;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG' | null;
filterJson: string;
isPublic?: boolean;
}
@@ -98,7 +98,7 @@ export class MagicShelfService {
const payload: MagicShelf = {
id: data.id,
name: data.name ?? '',
icon: data.icon ?? 'pi pi-book',
icon: data.icon,
iconType: data.iconType,
filterJson: JSON.stringify(data.group),
isPublic: data.isPublic ?? false

View File

@@ -21,6 +21,10 @@ import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
[ngStyle]="getSvgStyle()"
></div>
}
} @else if (displayEmpty) {
<div
[ngStyle]="getEmptyIconStyle()"
></div>
}
`,
styles: [`
@@ -50,6 +54,7 @@ export class IconDisplayComponent implements OnInit, OnChanges {
@Input() iconStyle: Record<string, string> = {};
@Input() size: string = '16px';
@Input() alt: string = 'Icon';
@Input() displayEmpty: boolean = false;
private iconCache = inject(IconCacheService);
private iconService = inject(IconService);
@@ -115,6 +120,13 @@ export class IconDisplayComponent implements OnInit, OnChanges {
};
}
getEmptyIconStyle(): Record<string, string> {
return {
width: this.size,
height: this.size,
};
}
getPrimeNgStyle(): Record<string, string> {
const fontSize = this.size.endsWith('px')
? `${parseInt(this.size) * 0.85}px`

View File

@@ -109,8 +109,8 @@ export class AppMenuComponent implements OnInit {
menu: this.libraryShelfMenuService.initializeLibraryMenuItems(library),
label: library.name,
type: 'Library',
icon: library.icon,
iconType: (library.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
icon: library.icon || undefined,
iconType: (library.iconType || undefined) as 'PRIME_NG' | 'CUSTOM_SVG' | undefined,
routerLink: [`/library/${library.id}/books`],
bookCount$: this.libraryService.getBookCount(library.id ?? 0),
})),
@@ -132,8 +132,8 @@ export class AppMenuComponent implements OnInit {
items: sortedShelves.map((shelf) => ({
label: shelf.name,
type: 'magicShelfItem',
icon: shelf.icon || 'pi pi-book',
iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
icon: shelf.icon || undefined,
iconType: (shelf.iconType || undefined) as 'PRIME_NG' | 'CUSTOM_SVG' | undefined,
menu: this.libraryShelfMenuService.initializeMagicShelfMenuItems(shelf),
routerLink: [`/magic-shelf/${shelf.id}/books`],
bookCount$: this.magicShelfService.getBookCount(shelf.id ?? 0),
@@ -158,8 +158,8 @@ export class AppMenuComponent implements OnInit {
menu: this.libraryShelfMenuService.initializeShelfMenuItems(shelf),
label: shelf.name,
type: 'Shelf',
icon: shelf.icon,
iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
icon: shelf.icon || undefined,
iconType: (shelf.iconType || undefined) as 'PRIME_NG' | 'CUSTOM_SVG' | undefined,
routerLink: [`/shelf/${shelf.id}/books`],
bookCount$: this.shelfService.getBookCount(shelf.id ?? 0),
}));
@@ -173,13 +173,13 @@ export class AppMenuComponent implements OnInit {
bookCount$: this.shelfService.getUnshelvedBookCount?.() ?? of(0),
};
const items = [unshelvedItem];
const items: MenuItem[] = [unshelvedItem];
if (koboShelf) {
items.push({
label: koboShelf.name,
type: 'Shelf',
icon: koboShelf.icon,
iconType: (koboShelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
icon: koboShelf.icon || undefined,
iconType: (koboShelf.iconType || undefined) as 'PRIME_NG' | 'CUSTOM_SVG' | undefined,
routerLink: [`/shelf/${koboShelf.id}/books`],
bookCount$: this.shelfService.getBookCount(koboShelf.id ?? 0),
});

View File

@@ -57,6 +57,7 @@
[icon]="getIconSelection()"
iconClass="layout-menuitem-icon"
size="18px"
[displayEmpty]="true"
></app-icon-display>
<span class="layout-menuitem-text menu-item-text">
{{ item.label }}