diff --git a/backend/src/applications/users/dto/user-properties.dto.spec.ts b/backend/src/applications/users/dto/user-properties.dto.spec.ts new file mode 100644 index 000000000..bc6a38868 --- /dev/null +++ b/backend/src/applications/users/dto/user-properties.dto.spec.ts @@ -0,0 +1,9 @@ +import { userPreferencesSchema } from './user-properties.dto.js' + +describe('userPreferencesSchema', () => { + it('accepts only boolean values for the single-click preference', () => { + expect(userPreferencesSchema.parse({ singleClickNavigation: true })).toEqual({ singleClickNavigation: true }) + expect(userPreferencesSchema.parse({})).toEqual({}) + expect(userPreferencesSchema.safeParse({ singleClickNavigation: 'true' }).success).toBe(false) + }) +}) diff --git a/backend/src/applications/users/dto/user-properties.dto.ts b/backend/src/applications/users/dto/user-properties.dto.ts index b134c869e..fc1b99060 100644 --- a/backend/src/applications/users/dto/user-properties.dto.ts +++ b/backend/src/applications/users/dto/user-properties.dto.ts @@ -28,6 +28,7 @@ export const userPreferencesSchema = z.object({ theme: z.enum(USER_THEME).optional(), editor: z.enum([COLLABORA_EDITOR, ONLY_OFFICE_EDITOR, EURO_OFFICE_EDITOR]).nullish(), showHiddenFiles: z.boolean().optional(), + singleClickNavigation: z.boolean().optional(), useSystemNotifications: z.boolean().optional(), sidebarQuickAccessVisibility: z.enum(USER_SIDEBAR_QUICK_ACCESS_VISIBILITY).optional(), sidebarQuickAccessPosition: z.enum(USER_SIDEBAR_QUICK_ACCESS_POSITION).optional() diff --git a/backend/src/applications/users/interfaces/user-preferences.interface.ts b/backend/src/applications/users/interfaces/user-preferences.interface.ts index 2badb5ac6..b50930181 100644 --- a/backend/src/applications/users/interfaces/user-preferences.interface.ts +++ b/backend/src/applications/users/interfaces/user-preferences.interface.ts @@ -7,6 +7,7 @@ export interface UserPreferences { theme: USER_THEME editor: Exclude | null showHiddenFiles: boolean + singleClickNavigation: boolean useSystemNotifications: boolean sidebarQuickAccessVisibility: USER_SIDEBAR_QUICK_ACCESS_VISIBILITY sidebarQuickAccessPosition: USER_SIDEBAR_QUICK_ACCESS_POSITION diff --git a/backend/src/applications/users/models/user.model.spec.ts b/backend/src/applications/users/models/user.model.spec.ts index 4cccb195a..4e1d3a1d8 100644 --- a/backend/src/applications/users/models/user.model.spec.ts +++ b/backend/src/applications/users/models/user.model.spec.ts @@ -2,12 +2,19 @@ import { USER_ROLE } from '../constants/user.js' import { UserModel } from './user.model.js' describe(UserModel.name, () => { - it('shows hidden files by default and preserves an explicit opt-out', () => { + it('normalizes file preferences and preserves explicit values', () => { const defaultUser = new UserModel({ id: 1, login: 'alice', role: USER_ROLE.USER }) - const optOutUser = new UserModel({ id: 2, login: 'bob', role: USER_ROLE.USER, preferences: { showHiddenFiles: false } }) + const customizedUser = new UserModel({ + id: 2, + login: 'bob', + role: USER_ROLE.USER, + preferences: { showHiddenFiles: false, singleClickNavigation: true } + }) expect(defaultUser.preferences.showHiddenFiles).toBe(true) - expect(optOutUser.preferences.showHiddenFiles).toBe(false) + expect(defaultUser.preferences.singleClickNavigation).toBe(false) + expect(customizedUser.preferences.showHiddenFiles).toBe(false) + expect(customizedUser.preferences.singleClickNavigation).toBe(true) }) it('serializes only public user properties', () => { diff --git a/backend/src/applications/users/services/users-manager.service.spec.ts b/backend/src/applications/users/services/users-manager.service.spec.ts index b6404f70e..6d5512cb1 100755 --- a/backend/src/applications/users/services/users-manager.service.spec.ts +++ b/backend/src/applications/users/services/users-manager.service.spec.ts @@ -258,6 +258,18 @@ describe(UsersManager.name, () => { expect(clearWebDAVAuthCache).toHaveBeenCalledWith(userTest.id) }) + it('should update the single-click preference without clearing the WebDAV auth cache', async () => { + const preferences = { ...userTest.preferences, singleClickNavigation: true } + const updatePreferences = vi.spyOn(usersQueriesService, 'updateUserPreferences').mockResolvedValue(preferences) + const clearWebDAVAuthCache = vi.spyOn(usersQueriesService, 'clearWebDAVAuthCache').mockResolvedValue() + clearWebDAVAuthCache.mockClear() + + await expect(usersManager.updatePreferences(userTest, { singleClickNavigation: true })).resolves.toEqual(preferences) + + expect(updatePreferences).toHaveBeenCalledWith(userTest.id, { singleClickNavigation: true }) + expect(clearWebDAVAuthCache).not.toHaveBeenCalled() + }) + it('paths + avatars (default/generate) + create/delete user', async () => { await expect(ensurePaths()).resolves.not.toThrow() expect(await isPathExists(userTest.filesPath)).toBe(true) diff --git a/backend/src/applications/users/services/users-manager.service.ts b/backend/src/applications/users/services/users-manager.service.ts index a5fa44774..e0d29e705 100755 --- a/backend/src/applications/users/services/users-manager.service.ts +++ b/backend/src/applications/users/services/users-manager.service.ts @@ -254,6 +254,7 @@ export class UsersManager { if (dto.theme !== undefined) patch.theme = dto.theme if (dto.editor !== undefined) patch.editor = dto.editor if (dto.showHiddenFiles !== undefined) patch.showHiddenFiles = dto.showHiddenFiles + if (dto.singleClickNavigation !== undefined) patch.singleClickNavigation = dto.singleClickNavigation if (dto.useSystemNotifications !== undefined) patch.useSystemNotifications = dto.useSystemNotifications if (dto.sidebarQuickAccessVisibility !== undefined) patch.sidebarQuickAccessVisibility = dto.sidebarQuickAccessVisibility if (dto.sidebarQuickAccessPosition !== undefined) patch.sidebarQuickAccessPosition = dto.sidebarQuickAccessPosition diff --git a/backend/src/applications/users/services/users-queries.service.spec.ts b/backend/src/applications/users/services/users-queries.service.spec.ts index b193bccdc..18383a438 100644 --- a/backend/src/applications/users/services/users-queries.service.spec.ts +++ b/backend/src/applications/users/services/users-queries.service.spec.ts @@ -64,3 +64,36 @@ describe(`${UsersQueries.name}.WebDAVAuthCache`, () => { expect(clearWebDAVAuthCache).toHaveBeenCalledWith(42) }) }) + +describe(`${UsersQueries.name}.updateUserPreferences`, () => { + it('persists a change to the single-click preference', async () => { + const forUpdate = vi.fn().mockResolvedValue([{ preferences: {} }]) + const selectWhere = vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ for: forUpdate }) }) + const select = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }) + const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]) + const set = vi.fn().mockReturnValue({ where: updateWhere }) + const transaction = vi.fn(async (callback: (tx: object) => unknown) => callback({ select, update: vi.fn().mockReturnValue({ set }) })) + const usersQueries = new UsersQueries({ transaction } as unknown as DBSchema, {} as Cache) + + await expect(usersQueries.updateUserPreferences(42, { singleClickNavigation: true })).resolves.toEqual( + expect.objectContaining({ singleClickNavigation: true }) + ) + + expect(set).toHaveBeenCalledWith({ preferences: expect.objectContaining({ singleClickNavigation: true }) }) + }) + + it('does not write the default single-click preference when it is unchanged', async () => { + const forUpdate = vi.fn().mockResolvedValue([{ preferences: {} }]) + const selectWhere = vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ for: forUpdate }) }) + const select = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }) + const update = vi.fn() + const transaction = vi.fn(async (callback: (tx: object) => unknown) => callback({ select, update })) + const usersQueries = new UsersQueries({ transaction } as unknown as DBSchema, {} as Cache) + + await expect(usersQueries.updateUserPreferences(42, { singleClickNavigation: false })).resolves.toEqual( + expect.objectContaining({ singleClickNavigation: false }) + ) + + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/backend/src/applications/users/services/users-queries.service.ts b/backend/src/applications/users/services/users-queries.service.ts index b045da598..e7d6bb13f 100644 --- a/backend/src/applications/users/services/users-queries.service.ts +++ b/backend/src/applications/users/services/users-queries.service.ts @@ -194,6 +194,7 @@ export class UsersQueries { preferences.theme === currentPreferences.theme && preferences.editor === currentPreferences.editor && preferences.showHiddenFiles === currentPreferences.showHiddenFiles && + preferences.singleClickNavigation === currentPreferences.singleClickNavigation && preferences.useSystemNotifications === currentPreferences.useSystemNotifications && preferences.sidebarQuickAccessVisibility === currentPreferences.sidebarQuickAccessVisibility && preferences.sidebarQuickAccessPosition === currentPreferences.sidebarQuickAccessPosition diff --git a/backend/src/applications/users/utils/user.ts b/backend/src/applications/users/utils/user.ts index 0990ee97a..724ded9ad 100644 --- a/backend/src/applications/users/utils/user.ts +++ b/backend/src/applications/users/utils/user.ts @@ -6,6 +6,7 @@ export function normalizeUserPreferences(preferences?: Partial theme: preferences?.theme ?? USER_THEME.AUTO, editor: preferences?.editor ?? null, showHiddenFiles: preferences?.showHiddenFiles ?? true, + singleClickNavigation: preferences?.singleClickNavigation ?? false, useSystemNotifications: preferences?.useSystemNotifications ?? true, sidebarQuickAccessVisibility: preferences?.sidebarQuickAccessVisibility ?? USER_SIDEBAR_QUICK_ACCESS_VISIBILITY.BOTH, sidebarQuickAccessPosition: preferences?.sidebarQuickAccessPosition ?? USER_SIDEBAR_QUICK_ACCESS_POSITION.TOP diff --git a/frontend/src/app/applications/admin/components/admin-groups.component.html b/frontend/src/app/applications/admin/components/admin-groups.component.html index f36235852..ed50bac8a 100644 --- a/frontend/src/app/applications/admin/components/admin-groups.component.html +++ b/frontend/src/app/applications/admin/components/admin-groups.component.html @@ -102,7 +102,7 @@ @for (m of scrollView?.viewPortItems; track m.mid) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @if (th.value.show) { diff --git a/frontend/src/app/applications/admin/components/admin-groups.component.ts b/frontend/src/app/applications/admin/components/admin-groups.component.ts index 0335ab8a7..d4722785c 100644 --- a/frontend/src/app/applications/admin/components/admin-groups.component.ts +++ b/frontend/src/app/applications/admin/components/admin-groups.component.ts @@ -173,6 +173,11 @@ export class AdminGroupsComponent { this.selected = member } + onMemberActivate(member: MemberModel) { + this.onSelect(member) + this.browse(member) + } + onContextMenu(ev: any) { ev.preventDefault() ev.stopPropagation() diff --git a/frontend/src/app/applications/admin/components/admin-spaces.component.html b/frontend/src/app/applications/admin/components/admin-spaces.component.html index 6ef94410d..f69606fd4 100644 --- a/frontend/src/app/applications/admin/components/admin-spaces.component.html +++ b/frontend/src/app/applications/admin/components/admin-spaces.component.html @@ -67,7 +67,7 @@ @for (s of scrollView?.viewPortItems; track s.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @@ -100,7 +100,7 @@ } @case ('info') { @if (s.counts.shares) { - + {{ s.counts.shares }} diff --git a/frontend/src/app/applications/admin/components/admin-spaces.component.ts b/frontend/src/app/applications/admin/components/admin-spaces.component.ts index 744102b64..7c5e0ac52 100644 --- a/frontend/src/app/applications/admin/components/admin-spaces.component.ts +++ b/frontend/src/app/applications/admin/components/admin-spaces.component.ts @@ -165,6 +165,11 @@ export class AdminSpacesComponent { this.selected = space ?? null } + onSpaceActivate(space: SpaceModel) { + this.onSelect(space) + this.openSpaceDialog() + } + sortBy(column: string, toUpdate = true, collection?: SpaceModel[]) { this.spaces = this.sortTable.sortBy(column, toUpdate, collection || this.spaces) } diff --git a/frontend/src/app/applications/admin/components/admin-users.component.html b/frontend/src/app/applications/admin/components/admin-users.component.html index 421f0cf8c..90e08a900 100644 --- a/frontend/src/app/applications/admin/components/admin-users.component.html +++ b/frontend/src/app/applications/admin/components/admin-users.component.html @@ -92,7 +92,7 @@ @for (u of scrollView?.viewPortItems; track u.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { diff --git a/frontend/src/app/applications/admin/components/admin-users.component.ts b/frontend/src/app/applications/admin/components/admin-users.component.ts index a6b124b28..dd4ba2776 100644 --- a/frontend/src/app/applications/admin/components/admin-users.component.ts +++ b/frontend/src/app/applications/admin/components/admin-users.component.ts @@ -68,6 +68,7 @@ export class AdminUsersComponent { @ViewChild('TargetContextMenu', { static: true }) targetContextMenu: ContextMenuComponent protected readonly locale = inject(L10N_LOCALE) protected readonly navbarSearch = inject(NavbarSearchService) + private readonly userService = inject(UserService) protected readonly originalOrderKeyValue = originalOrderKeyValue protected readonly icons = { LucideRotateCw, @@ -147,7 +148,6 @@ export class AdminUsersComponent { private readonly activatedRoute = inject(ActivatedRoute) private readonly layout = inject(LayoutService) private readonly adminService = inject(AdminService) - private readonly userService = inject(UserService) private readonly sortSettings: SortSettings = { default: [{ prop: 'login', type: 'string' }], login: [{ prop: 'login', type: 'string' }], @@ -189,6 +189,11 @@ export class AdminUsersComponent { this.selected = user } + onUserActivate(user: AdminUserModel) { + this.onSelect(user) + this.openDialog() + } + onGuestsView(state: boolean) { this.guestsView = state this.router.navigate([ADMIN_PATH.BASE, state ? ADMIN_PATH.GUESTS : ADMIN_PATH.USERS]).catch(console.error) diff --git a/frontend/src/app/applications/favorites/components/favorites.component.html b/frontend/src/app/applications/favorites/components/favorites.component.html index 89b4c832f..bccdcfab9 100644 --- a/frontend/src/app/applications/favorites/components/favorites.component.html +++ b/frontend/src/app/applications/favorites/components/favorites.component.html @@ -48,7 +48,7 @@ @for (f of scrollView?.viewPortItems; track f.fileId) {
@@ -71,12 +71,14 @@ @switch (badge) { @case ('comments') { } @case ('shares') { @if (f.shares.length > 1) { @@ -86,6 +88,7 @@ } @case ('spaces') { @if (f.spaces.length > 1) { @@ -95,6 +98,7 @@ } @case ('links') { @if (f.links.length > 1) { @@ -104,6 +108,7 @@ } @case ('syncs') { @if (f.syncs.length > 1) { @@ -116,6 +121,7 @@ } @if (hiddenBadges > 0) { {{ hiddenBadges }} @@ -172,7 +178,7 @@ @for (f of scrollView?.viewPortItems; track f.fileId) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @@ -205,12 +211,14 @@ @case ('info') { @if (f.hasComments) { } @if (f.shares.length) { @if (f.shares.length > 1) { @@ -220,6 +228,7 @@ } @if (f.links.length) { @if (f.links.length > 1) { @@ -229,6 +238,7 @@ } @if (f.spaces.length) { @if (f.spaces.length > 1) { @@ -238,6 +248,7 @@ } @if (f.syncs.length) { @if (f.syncs.length > 1) { diff --git a/frontend/src/app/applications/favorites/components/favorites.component.ts b/frontend/src/app/applications/favorites/components/favorites.component.ts index 80e2c62c2..715cb061d 100644 --- a/frontend/src/app/applications/favorites/components/favorites.component.ts +++ b/frontend/src/app/applications/favorites/components/favorites.component.ts @@ -191,6 +191,11 @@ export class FavoritesComponent implements OnInit { this.selected = favorite } + onFavoriteActivate(favorite: FileFavoriteModel) { + this.onSelect(favorite) + this.goTo(favorite) + } + onContextMenu(ev: Event) { ev.preventDefault() ev.stopPropagation() diff --git a/frontend/src/app/applications/files/components/sidebar/files-tree.component.html b/frontend/src/app/applications/files/components/sidebar/files-tree.component.html index 6d920c5d7..e354fd8d0 100644 --- a/frontend/src/app/applications/files/components/sidebar/files-tree.component.html +++ b/frontend/src/app/applications/files/components/sidebar/files-tree.component.html @@ -133,6 +133,7 @@
@@ -134,7 +134,7 @@ @for (s of scrollView?.viewPortItems; track s.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { diff --git a/frontend/src/app/applications/links/components/links.component.ts b/frontend/src/app/applications/links/components/links.component.ts index 4cdd5cd79..7f246c511 100644 --- a/frontend/src/app/applications/links/components/links.component.ts +++ b/frontend/src/app/applications/links/components/links.component.ts @@ -192,6 +192,11 @@ export class LinksComponent implements OnInit { this.store.linkSelection.set(this.selected) } + onLinkActivate(share: ShareLinkModel) { + this.onSelect(share) + this.openLinkDialog() + } + openLinkDialog() { this.linksService.shareLink(this.selected.id).subscribe((share: ShareLinkModel) => { const modalRef: BsModalRef = this.layout.openDialog(LinkDialogComponent, 'lg', { diff --git a/frontend/src/app/applications/search/components/search.component.html b/frontend/src/app/applications/search/components/search.component.html index dba7d8590..1c67b468e 100644 --- a/frontend/src/app/applications/search/components/search.component.html +++ b/frontend/src/app/applications/search/components/search.component.html @@ -1,11 +1,15 @@ -
+
@if (errorMessage) {
{{ errorMessage }}
} @for (file of store.filesSearch() | searchFilter:navbarSearch.viewFilter(); track $index) {
@if (file.matches?.length) { -
+
@for (match of file.matches; track $index) {

} diff --git a/frontend/src/app/applications/search/components/search.component.scss b/frontend/src/app/applications/search/components/search.component.scss index e3c3a6f8f..fc5db94c9 100644 --- a/frontend/src/app/applications/search/components/search.component.scss +++ b/frontend/src/app/applications/search/components/search.component.scss @@ -27,23 +27,15 @@ background-color: var(--search-result-hover-bg); } - &:active { - background-color: var(--search-result-hover-bg); - } - &:focus-visible { z-index: 1; outline: 2px solid var(--search-result-focus-ring); outline-offset: -2px; } - &.selected { + &:is(.selected, :active) { background-color: var(--search-result-selected-bg); - &:active { - background-color: var(--search-result-hover-bg); - } - &::before { position: absolute; inset: 0 auto 0 0; @@ -88,6 +80,9 @@ display: flex; flex-direction: column; gap: .3rem; + cursor: text; + user-select: text; + -webkit-touch-callout: default; } .search-result-match { diff --git a/frontend/src/app/applications/search/components/search.component.ts b/frontend/src/app/applications/search/components/search.component.ts index 32d5c17bf..82b645e38 100644 --- a/frontend/src/app/applications/search/components/search.component.ts +++ b/frontend/src/app/applications/search/components/search.component.ts @@ -96,6 +96,11 @@ export class SearchComponent { this.router.navigate([SPACES_PATH.SPACES, ...file.path.split('/')], { queryParams: { select: file.name } }).catch(console.error) } + protected onFileActivate(file: FileContentModel, index: number) { + this.selectedId = index + this.goTo(file) + } + private searchFromParams(params: ParamMap): NavbarGlobalSearch | null { const content = params.get(SEARCH_QUERY_PARAM.QUERY)?.trim() ?? '' const mode = params.get(SEARCH_QUERY_PARAM.SCOPE) diff --git a/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html b/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html index d7fc5f7e7..885447601 100644 --- a/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html +++ b/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html @@ -14,7 +14,10 @@