Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
1 change: 1 addition & 0 deletions backend/src/applications/users/dto/user-properties.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface UserPreferences {
theme: USER_THEME
editor: Exclude<keyof FileEditorProviders, 'drawio'> | null
showHiddenFiles: boolean
singleClickNavigation: boolean
useSystemNotifications: boolean
sidebarQuickAccessVisibility: USER_SIDEBAR_QUICK_ACCESS_VISIBILITY
sidebarQuickAccessPosition: USER_SIDEBAR_QUICK_ACCESS_POSITION
Expand Down
13 changes: 10 additions & 3 deletions backend/src/applications/users/models/user.model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/src/applications/users/utils/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function normalizeUserPreferences(preferences?: Partial<UserPreferences>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
@for (m of scrollView?.viewPortItems; track m.mid) {
<tr (click)="onSelect(m)"
(contextmenu)="onTargetContextMenu($event, m)"
(appTap)="browse(m)"
(appTap)="onMemberActivate(m)"
[class.selected]="m.mid === selected?.mid">
@for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) {
@if (th.value.show) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
@for (s of scrollView?.viewPortItems; track s.id) {
<tr (click)="onSelect(s)"
(contextmenu)="onTargetContextMenu($event, s)"
(appTap)="openSpaceDialog()"
(appTap)="onSpaceActivate(s)"
[class.disabled]="!s.enabled"
[class.selected]="s.id === selected?.id">
@for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) {
Expand Down Expand Up @@ -100,7 +100,7 @@
}
@case ('info') {
@if (s.counts.shares) {
<span (click)="openChildShareDialog(s)" class="badge bg-purple cursor-pointer me-1">
<span (click)="openChildShareDialog(s)" class="badge bg-purple cursor-pointer me-1" data-app-tap-ignore>
<svg [lucideIcon]="icons.SHARES"></svg>
{{ s.counts.shares }}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
@for (u of scrollView?.viewPortItems; track u.id) {
<tr (click)="onSelect(u)"
(contextmenu)="onTargetContextMenu($event, u)"
(appTap)="openDialog()"
(appTap)="onUserActivate(u)"
[class.disabled]="!u.isActive"
[class.selected]="u.id === selected?.id">
@for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class AdminUsersComponent {
@ViewChild('TargetContextMenu', { static: true }) targetContextMenu: ContextMenuComponent<any>
protected readonly locale = inject<L10nLocale>(L10N_LOCALE)
protected readonly navbarSearch = inject(NavbarSearchService)
private readonly userService = inject(UserService)
protected readonly originalOrderKeyValue = originalOrderKeyValue
protected readonly icons = {
LucideRotateCw,
Expand Down Expand Up @@ -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' }],
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
@for (f of scrollView?.viewPortItems; track f.fileId) {
<div (click)="onSelect(f)"
(contextmenu)="onTargetContextMenu($event, f)"
(appTap)="goTo(f)"
(appTap)="onFavoriteActivate(f)"
[class.disabled]="f.isDisabled"
[class.selected]="f.fileId === selected?.fileId"
class="card">
Expand All @@ -71,12 +71,14 @@
@switch (badge) {
@case ('comments') {
<span (click)="goToDetails($event, f, TAB_MENU.COMMENTS)"
data-app-tap-ignore
class="badge bg-success cursor-pointer">
<svg [lucideIcon]="icons.LucideMessageSquareMore"></svg>
</span>
}
@case ('shares') {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.SHARED"></svg>
@if (f.shares.length > 1) {
Expand All @@ -86,6 +88,7 @@
}
@case ('spaces') {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.SPACES"></svg>
@if (f.spaces.length > 1) {
Expand All @@ -95,6 +98,7 @@
}
@case ('links') {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.LINKS"></svg>
@if (f.links.length > 1) {
Expand All @@ -104,6 +108,7 @@
}
@case ('syncs') {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.SYNC"></svg>
@if (f.syncs.length > 1) {
Expand All @@ -116,6 +121,7 @@
}
@if (hiddenBadges > 0) {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.LucideCirclePlus"></svg>
{{ hiddenBadges }}
Expand Down Expand Up @@ -172,7 +178,7 @@
@for (f of scrollView?.viewPortItems; track f.fileId) {
<tr (click)="onSelect(f)"
(contextmenu)="onTargetContextMenu($event, f)"
(appTap)="goTo(f)"
(appTap)="onFavoriteActivate(f)"
[class.disabled]="f.isDisabled"
[class.selected]="f.fileId === selected?.fileId">
@for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) {
Expand Down Expand Up @@ -205,12 +211,14 @@
@case ('info') {
@if (f.hasComments) {
<span (click)="goToDetails($event, f, TAB_MENU.COMMENTS)"
data-app-tap-ignore
class="badge bg-success cursor-pointer me-1">
<svg [lucideIcon]="icons.LucideMessageSquareMore"></svg>
</span>
}
@if (f.shares.length) {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer me-1">
<svg [lucideIcon]="icons.SHARED"></svg>
@if (f.shares.length > 1) {
Expand All @@ -220,6 +228,7 @@
}
@if (f.links.length) {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer me-1">
<svg [lucideIcon]="icons.LINKS"></svg>
@if (f.links.length > 1) {
Expand All @@ -229,6 +238,7 @@
}
@if (f.spaces.length) {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer me-1">
<svg [lucideIcon]="icons.SPACES"></svg>
@if (f.spaces.length > 1) {
Expand All @@ -238,6 +248,7 @@
}
@if (f.syncs.length) {
<span (click)="goToDetails($event, f, TAB_MENU.SELECTION)"
data-app-tap-ignore
class="badge bg-primary cursor-pointer">
<svg [lucideIcon]="icons.SYNC"></svg>
@if (f.syncs.length > 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
<ng-template #treeNodeWrapperTemplate let-index="index" let-node>
<div (click)="node.mouseAction('click', $event)"
(appTap)="node.mouseAction('dblClick', $event)"
[enableDoubleTap]="true"
[class.tree-selected]="node.isActive"
[class.tree-disabled]="!node.data.enabled"
[style.padding-left]="node.getNodePadding()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
@for (s of scrollView?.viewPortItems; track s.id) {
<div (click)="onSelect(s)"
(contextmenu)="onTargetContextMenu($event, s)"
(appTap)="openLinkDialog()"
(appTap)="onLinkActivate(s)"
[class.selected]="s.id === selected?.id"
[class.disabled]="!s.link.isActive"
class="card">
Expand Down Expand Up @@ -134,7 +134,7 @@
@for (s of scrollView?.viewPortItems; track s.id) {
<tr (click)="onSelect(s)"
(contextmenu)="onTargetContextMenu($event, s)"
(appTap)="openLinkDialog()"
(appTap)="onLinkActivate(s)"
[class.selected]="s.id === selected?.id"
[class.disabled]="!s.link.isActive">
@for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LinkDialogComponent> = this.layout.openDialog(LinkDialogComponent, 'lg', {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<div appAutoResize [resizeOffset]="45" class="search-results-list" role="list">
<div appAutoResize
[resizeOffset]="45"
class="search-results-list"
role="list">
@if (errorMessage) {
<div class="p-3 text-danger fw-500" l10nTranslate>{{ errorMessage }}</div>
}
@for (file of store.filesSearch() | searchFilter:navbarSearch.viewFilter(); track $index) {
<article
(click)="selectedId = $index"
(appTap)="goTo(file)"
(appTap)="onFileActivate(file, $index)"
[enableDoubleTap]="false"
(keydown.enter)="goTo(file)"
(keydown.space)="$event.preventDefault(); selectedId = $index"
[class.selected]="$index === selectedId"
Expand All @@ -20,7 +24,7 @@
</div>
</div>
@if (file.matches?.length) {
<div class="search-result-matches">
<div class="search-result-matches" data-app-tap-ignore>
@for (match of file.matches; track $index) {
<p class="search-result-match" [innerHTML]="match"></p>
}
Expand Down
Loading
Loading