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
Expand Up @@ -210,6 +210,29 @@ describe('DotFolderService', () => {
const req = spectator.expectOne(url, HttpMethod.GET);
req.flush({ entity: [], pagination: { currentPage: 1, perPage: 40, totalEntries: 0 } });
});

it('should send includePermissions when the caller opts in', () => {
spectator.service
.searchFolders({ siteId: 'site-1', includePermissions: true })
.subscribe();

const url =
'/api/v1/folder/search?siteId=site-1&includePermissions=true&page=1&per_page=40';
const req = spectator.expectOne(url, HttpMethod.GET);
req.flush({ entity: [], pagination: mockPagination });
});

it('should omit includePermissions entirely when not requested', () => {
// The backend defaults it to false, so sending `includePermissions=false` on every tree
// request would be pure noise.
spectator.service
.searchFolders({ siteId: 'site-1', includePermissions: false })
.subscribe();

const url = '/api/v1/folder/search?siteId=site-1&page=1&per_page=40';
const req = spectator.expectOne(url, HttpMethod.GET);
req.flush({ entity: [], pagination: mockPagination });
});
});

describe('createFolder', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class DotFolderService {
httpParams = httpParams.set('direction', params.direction);
}

// Only sent when opting in: the backend defaults it to false and caps `per_page` when it is
// true, so an always-on `includePermissions=false` would be noise on every tree request.
if (params.includePermissions) {
httpParams = httpParams.set('includePermissions', 'true');
}

httpParams = httpParams.set('page', String(params.page ?? DEFAULT_FOLDER_SEARCH_PAGE));
httpParams = httpParams.set(
'per_page',
Expand Down
61 changes: 48 additions & 13 deletions core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,58 @@ export interface DotContentDriveLazyLoadEvent {
forceUpdate?: () => void;
}

export interface DotContentDriveFolder {
__icon__: 'folderIcon';
/**
* The folder fields required to drive the shared folder actions — the context menu's permission
* gating and the "Edit folder" dialog's payload.
*
* Deliberately narrower than {@link DotContentDriveFolder}: the table sources folders from
* `POST /api/v1/drive/search` (a full folder row), while the sidebar tree sources them from
* `GET /api/v1/folder/search`, which returns only the fields listed here. Rather than fabricate
* the table-only fields (`modDate`, `owner`, `iDate`, …) for sidebar folders, both views converge
* on this contract, and only consumers that genuinely need the full row ask for
* `DotContentDriveFolder`.
*/
export interface DotContentDriveActionableFolder {
type: 'folder';
identifier: string;
/** The folder's own name (last path segment). */
name: string;
/** The folder's own full path, e.g. `/application/blog/`. */
path: string;
title: string;
sortOrder: number;
showOnMenu: boolean;
/** Comma-separated file-name masks allowed in this folder, e.g. `*.jpg,*.png`. */
filesMasks: string;
defaultFileType: string;
/**
* Folder upload preference: `DOTASSET`/`FILEASSET` forces every upload to that base type,
* `null`/`undefined` means "ask each time" (no preference). Backed by #35577.
*/
defaultBaseType?: string | null;
/**
* Permission types the requesting user holds on this folder.
*
* Required, and always an array by the time a folder reaches an action: whoever builds this
* object resolves the folder's permissions first (or substitutes `[]` when they cannot be
* resolved), so gating never runs against `null`/`undefined`. The "not yet resolved" state
* lives upstream, on `DotFolder.permissions` / the tree node's data.
*/
permissions: PermissionType[];
}

export interface DotContentDriveFolder extends DotContentDriveActionableFolder {
__icon__: 'folderIcon';
description: string;
extension: 'folder';
filesMasks: string;
hasTitleImage: boolean;
hostId: string;
iDate: number;
identifier: string;
inode: string;
mimeType: string;
modDate: number;
name: string;
owner: string | null;
parent: string;
path: string;
permissions: PermissionType[];
showOnMenu: boolean;
sortOrder: number;
title: string;
type: 'folder';
}

export const PERMISSIONS_TYPE = {
Expand All @@ -60,6 +85,15 @@ export type PermissionType = (typeof PERMISSIONS_TYPE)[keyof typeof PERMISSIONS_
// but for now we will just use the DotCMSContentlet until we have folders on the request response
export type DotContentDriveItem = DotCMSContentlet | DotContentDriveFolder;

/**
* An item the shared folder actions (context menu, Edit-folder dialog) can act on.
*
* Wider than {@link DotContentDriveItem} on the folder side: the table passes a full
* {@link DotContentDriveFolder} and the sidebar tree passes a {@link DotContentDriveActionableFolder},
* so one gating implementation serves both. Every `DotContentDriveItem` is assignable to this.
*/
export type DotContentDriveActionableItem = DotCMSContentlet | DotContentDriveActionableFolder;

/**
* Pagination event emitted by the folder list view,
* extending the lazy-load event shape with a resolved 1-indexed page number.
Expand All @@ -70,11 +104,12 @@ export type DotContentDrivePaginateEvent = DotContentDriveLazyLoadEvent & { page
* Interface representing data needed for context menu interactions
* @interface ContextMenuData
* @property {Event} event - The DOM event that triggered the context menu
* @property {DotContentDriveItem} contentlet - The content item associated with the context menu
* @property {DotContentDriveActionableItem} contentlet - The item associated with the context menu.
* Accepts folders from either the table (full row) or the sidebar tree (search view).
*/
export interface ContextMenuData {
event: Event;
contentlet: DotContentDriveItem;
contentlet: DotContentDriveActionableItem;
}

/**
Expand Down
48 changes: 47 additions & 1 deletion core-web/libs/dotcms-models/src/lib/dot-folder.model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PermissionType } from './dot-content-drive.model';

/**
* Represents a folder in the DotCMS system
*
Expand All @@ -23,6 +25,25 @@ export interface DotFolder {
* `null`/`undefined` means "ask each time" (no preference). Backed by #35577.
*/
defaultBaseType?: string | null;
/**
* The folder's own name (last path segment). Populated by the folder-search adapter; other
* producers leave it unset and callers fall back to deriving it from `path`.
*/
name?: string;
/**
* Fields below back the shared folder actions (context menu gating + "Edit folder" dialog) and
* are populated only by {@link FolderSearchView} sources.
*
* `permissions` stays `undefined` when the search did not request them — distinct from `[]`
* ("resolved: the user holds none"), so a consumer can resolve them on demand instead of
* silently rendering an empty menu.
*/
title?: string;
sortOrder?: number;
filesMasks?: string;
defaultFileType?: string;
showOnMenu?: boolean;
permissions?: PermissionType[];
}

/**
Expand Down Expand Up @@ -74,9 +95,24 @@ export interface FolderSearchView {
hasChildren: boolean;
/**
* Folder upload preference (`DOTASSET`/`FILEASSET`, or `null`/absent for "ask each time").
* Pending backend support on `/api/v1/folder/search` — see #36649.
*/
defaultBaseType?: string | null;
title: string;
sortOrder: number;
/** Comma-separated file-name masks allowed in this folder, e.g. `*.jpg,*.png`. */
filesMasks: string;
/** Velocity variable name of the Content Type used by default for new files in this folder. */
defaultFileType: string;
showOnMenu: boolean;
/**
* Permission types the requesting user holds on this folder, drawn from the same set the table
* exposes (`READ`, `EDIT`, `PUBLISH`, `EDIT_PERMISSIONS`, `CAN_ADD_CHILDREN`).
*
* `null` when the request did not pass `includePermissions=true` — deliberately distinct from
* `[]` ("requested, but the user holds none"), so callers can tell "not fetched" from "no
* grants". Consumers must normalize before gating; see `folderSearchViewToDotFolder`.
*/
permissions: PermissionType[] | null;
}

/**
Expand All @@ -101,4 +137,14 @@ export interface FolderSearchParams {
direction?: 'ASC' | 'DESC';
page?: number;
per_page?: number;
/**
* Request per-folder `permissions` on each result. Off by default: resolving them costs extra
* batch permission queries per page, so only callers that are about to gate a folder action
* (e.g. opening the sidebar context menu) should opt in.
*
* The backend caps `per_page` when this is `true`
* (`content.drive.folder.search.permissions.max.per.page`, default 200) and rejects larger
* pages with a 400 — so never combine it with a bulk load such as the deep-link tree hydration.
*/
includePermissions?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MessageService } from 'primeng/api';
import { AutoComplete, AutoCompleteCompleteEvent } from 'primeng/autocomplete';

import { DotContentTypeService, DotFolderService, DotMessageService } from '@dotcms/data-access';
import { DotContentDriveFolder } from '@dotcms/dotcms-models';
import { DotContentDriveActionableFolder, DotContentDriveFolder } from '@dotcms/dotcms-models';
import { createFakeSite, MockDotMessageService } from '@dotcms/utils-testing';

import { DotContentDriveDialogFolderComponent } from './dot-content-drive-dialog-folder.component';
Expand Down Expand Up @@ -304,7 +304,7 @@ describe('DotContentDriveDialogFolderComponent', () => {
modDate: 1234567890,
owner: null,
parent: '',
path: '',
path: '/documents/existing-folder/',
permissions: [],
type: 'folder'
};
Expand Down Expand Up @@ -569,6 +569,34 @@ describe('DotContentDriveDialogFolderComponent', () => {
});

describe('upload behavior (defaultBaseType)', () => {
const editableFolder = (
overrides: Partial<DotContentDriveFolder> = {}
): DotContentDriveFolder =>
({
name: 'app',
title: 'App',
sortOrder: 1,
filesMasks: '',
defaultFileType: 'FileAsset',
showOnMenu: false,
__icon__: 'folderIcon',
description: '',
extension: 'folder',
hasTitleImage: false,
hostId: '1',
iDate: 1,
identifier: '1',
inode: '1',
mimeType: '',
modDate: 1,
owner: null,
parent: '',
path: '/documents/app/',
permissions: [],
type: 'folder',
...overrides
}) as DotContentDriveFolder;

it('should render the three upload-behavior options', () => {
expect(spectator.query('[data-testid="upload-behavior-option-null"]')).toBeTruthy();
expect(spectator.query('[data-testid="upload-behavior-option-DOTASSET"]')).toBeTruthy();
Expand Down Expand Up @@ -1001,7 +1029,7 @@ describe('DotContentDriveDialogFolderComponent', () => {
modDate: 1234567890,
owner: null,
parent: '',
path: '',
path: '/documents/original-folder/',
permissions: [],
type: 'folder'
};
Expand Down Expand Up @@ -1036,6 +1064,63 @@ describe('DotContentDriveDialogFolderComponent', () => {
expect(lastCall?.assetPath).toBe('//demo.dotcms.com/documents/test-folder/');
});

it('should anchor the edit assetPath on the folder itself, not the open folder', () => {
// The sidebar tree can open this dialog for any folder in the site, not just a child of
// the folder currently open in the drive (`store.path()` is '/documents'). Anchoring on
// the open path would build '//demo.dotcms.com/documents/marketing-assets/' — a
// different folder, which would 404 on save or silently overwrite a same-named sibling.
const folderInAnotherBranch: DotContentDriveActionableFolder = {
name: 'marketing-assets',
title: 'Marketing Assets',
sortOrder: 1,
filesMasks: '',
defaultFileType: 'FileAsset',
showOnMenu: false,
identifier: 'other-branch-id',
path: '/campaigns/2026/marketing-assets/',
permissions: [],
type: 'folder'
};

spectator.setInput('folder', folderInAnotherBranch);
spectator.detectChanges();

const saveButton = spectator.query(
'[data-testid="content-drive-dialog-folder-create"]'
);
spectator.click(saveButton);

expect(folderService.saveFolder).toHaveBeenCalled();
const lastCall = folderService.saveFolder.mock.calls.at(-1)?.[0];
expect(lastCall?.assetPath).toBe('//demo.dotcms.com/campaigns/2026/marketing-assets/');
});

it('should anchor the edit assetPath at the site root for a root-level folder', () => {
const rootLevelFolder: DotContentDriveActionableFolder = {
name: 'archive',
title: 'Archive',
sortOrder: 1,
filesMasks: '',
defaultFileType: 'FileAsset',
showOnMenu: false,
identifier: 'root-level-id',
path: '/archive/',
permissions: [],
type: 'folder'
};

spectator.setInput('folder', rootLevelFolder);
spectator.detectChanges();

const saveButton = spectator.query(
'[data-testid="content-drive-dialog-folder-create"]'
);
spectator.click(saveButton);

const lastCall = folderService.saveFolder.mock.calls.at(-1)?.[0];
expect(lastCall?.assetPath).toBe('//demo.dotcms.com/archive/');
});

it('should include name in data when originalName exists and name has changed', () => {
// Simulate editing an existing folder
const mockFolder: DotContentDriveFolder = {
Expand All @@ -1057,7 +1142,7 @@ describe('DotContentDriveDialogFolderComponent', () => {
modDate: 1234567890,
owner: null,
parent: '',
path: '',
path: '/documents/original-folder/',
permissions: [],
type: 'folder'
};
Expand Down Expand Up @@ -1122,7 +1207,7 @@ describe('DotContentDriveDialogFolderComponent', () => {
modDate: 1234567890,
owner: null,
parent: '',
path: '',
path: '/documents/original-folder/',
permissions: [],
type: 'folder'
};
Expand Down Expand Up @@ -1177,7 +1262,7 @@ describe('DotContentDriveDialogFolderComponent', () => {
modDate: 1234567890,
owner: null,
parent: '',
path: '',
path: '/documents/existing-folder/',
permissions: [],
type: 'folder'
};
Expand Down
Loading
Loading