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 @@ -8,6 +8,8 @@ import { DotAssetPickerComponent } from '@dotcms/ui';

import { AngularFormBridge } from './angular-form-bridge';

import { DotBrowserOptions } from '../interfaces/asset-browser.interface';

/** The site the picker browses; the bridge is handed a way to resolve it. */
const SITE: DotSite = {
identifier: 'site-1',
Expand Down Expand Up @@ -942,7 +944,6 @@ describe('AngularFormBridge', () => {
bridge.openBrowserModal();

expect(openedConfig().allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']);
expect(openedConfig().browse?.showFolders).toBeFalsy();
expect(openedConfig().browse?.showLinks).toBeFalsy();
});
});
Expand All @@ -954,12 +955,66 @@ describe('AngularFormBridge', () => {
expect(openedConfig().allowedBaseTypes).toEqual(['FILEASSET', 'HTMLPAGE']);
});

it('should map folder and link kinds to browse options', () => {
bridge.openBrowserModal({ kinds: ['page', 'folder', 'link'] });
it('should map the link kind to a browse option', () => {
bridge.openBrowserModal({ kinds: ['page', 'link'] });

expect(openedConfig().browse).toEqual(
expect.objectContaining({ showFolders: true, showLinks: true })
);
expect(openedConfig().browse).toEqual(expect.objectContaining({ showLinks: true }));
});

it('should not carry a folder browse option for a caller that asks for folders', () => {
// #37366: `'folder'` left the contract, but a VTL template is a string literal —
// TypeScript polices nothing here, so the runtime has to. The kind is dropped, and
// the picker is never handed an option that would list folders.
bridge.openBrowserModal({
kinds: ['page', 'folder', 'link']
} as unknown as DotBrowserOptions);

expect(openedConfig().browse).not.toHaveProperty('showFolders');
expect(openedConfig().browse).toEqual(expect.objectContaining({ showLinks: true }));
});

it('should warn about an unsupported kind rather than ignore it silently', () => {
// AC-008: a template author must not be able to ask for a kind the picker refuses
// and get no signal. Same treatment the `link` + `mimeTypes` conflict already gets.
const warn = jest.spyOn(console, 'warn').mockImplementation();

bridge.openBrowserModal({
kinds: ['file', 'page', 'folder']
} as unknown as DotBrowserOptions);

expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('folder');
expect(openedConfig().allowedBaseTypes).toEqual(['FILEASSET', 'HTMLPAGE']);

warn.mockRestore();
});

it('should fall back to asset-only browsing when folder is the only kind asked for', () => {
// Degenerate case: no requested kind maps to a base type, so `baseTypesFor` returns
// undefined and the picker uses its own default. Must not throw — an exception
// inside a VTL <script> takes the whole custom field down with it.
const warn = jest.spyOn(console, 'warn').mockImplementation();

expect(() =>
bridge.openBrowserModal({
kinds: ['folder']
} as unknown as DotBrowserOptions)
).not.toThrow();

expect(openedConfig().allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']);
expect(warn).toHaveBeenCalledTimes(1);

warn.mockRestore();
});

it('should not warn for a caller whose kinds are all supported', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation();

bridge.openBrowserModal({ kinds: ['file', 'dotasset', 'page', 'link'] });

expect(warn).not.toHaveBeenCalled();

warn.mockRestore();
});

it.each([
Expand Down Expand Up @@ -1069,26 +1124,9 @@ describe('AngularFormBridge', () => {
);
});

it('should report a folder with its path as the url', () => {
// A folder has no `url` — its path is what a custom field stores.
const onClose = jest.fn();
bridge.openBrowserModal({ kinds: ['folder'], onClose });
closeWith({
type: 'folder',
identifier: 'folder-1',
inode: 'folder-inode',
title: 'images',
path: '/images/'
});

expect(onClose).toHaveBeenCalledWith({
kind: 'folder',
identifier: 'folder-1',
inode: 'folder-inode',
title: 'images',
url: '/images/'
});
});
// Removed in #37366: "should report a folder with its path as the url". A folder can no
// longer be picked, so there is no selection to report. The path a custom field stores
// for a folder is now typed into the field, not returned by the picker.

it('should report a menu link with its target as the url', () => {
const onClose = jest.fn();
Expand All @@ -1112,10 +1150,8 @@ describe('AngularFormBridge', () => {
});

it.each([
[
'folder',
{ type: 'folder', identifier: 'f', inode: 'fi', title: 'f', path: '/f/' }
],
// The `folder` case went with #37366 — a folder is no longer a selectable kind, so
// there is no folder selection whose shape could be wrong.
['link', { extension: 'link', identifier: 'l', inode: 'li', title: 'l', url: '/l' }]
])('should not attach contentlet-only fields to a %s', (_kind, item) => {
// The whole point of the discriminated union: a consumer can never read a mimetype
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,14 @@ export class AngularFormBridge implements FormBridge {
}
}

/**
* Every kind the browse contract admits.
*
* Declared rather than derived from {@link KIND_BASE_TYPES}, which covers only the kinds that map
* onto a base type — `link` has none, so deriving would report it as unsupported.
*/
const SUPPORTED_KINDS: readonly DotBrowserItemKind[] = ['file', 'dotasset', 'page', 'link'];

/** Base types a `kinds` list asks the selector to offer. */
const KIND_BASE_TYPES: Partial<Record<DotBrowserItemKind, string>> = {
file: 'FILEASSET',
Expand Down Expand Up @@ -651,6 +659,21 @@ function browseOptionsFor(options: DotBrowserOptions): DotAssetPickerBrowseOptio
const kinds = options.kinds ?? [];
const wantsLinks = kinds.includes('link');

// Callers are VTL string literals, so `kinds` can carry anything the type no longer admits —
// `'folder'` above all, which `file_browser_field_render_new.vtl` asked for until #37366, and
// which a third-party template still might. Warn and drop it: an
// exception here would take the whole custom field down, while the rest of the request is still
// satisfiable. Mirrors the mimetype conflict below.
const unsupported = kinds.filter((kind) => !SUPPORTED_KINDS.includes(kind));

if (unsupported.length) {
console.warn(
`DotCustomFieldApi.openBrowserModal: unsupported kind(s) ${unsupported.join(', ')} — ` +
'ignored. Folders in particular are navigation, not content: they appear only in ' +
'the picker sidebar tree and can never be listed or returned.'
);
}

if (wantsLinks && options.mimeTypes?.length) {
// The browse endpoint drops links whenever a mimetype filter is set, because a link has no
// file metadata to match against. Surfaced rather than worked around: silently returning
Expand All @@ -662,7 +685,6 @@ function browseOptionsFor(options: DotBrowserOptions): DotAssetPickerBrowseOptio
}

return {
...(kinds.includes('folder') ? { showFolders: true } : {}),
...(wantsLinks ? { showLinks: true } : {}),
// Three states, expressed as the two flags the picker already understands.
...(options.status ? { showWorking: options.status !== 'live' } : {}),
Expand All @@ -673,12 +695,12 @@ function browseOptionsFor(options: DotBrowserOptions): DotAssetPickerBrowseOptio
};
}

/** What the picker returned, in the terms the item itself reports. */
/**
* What the picker returned, in the terms the item itself reports.
*
* No folder branch: the picker never lists a folder, so a row can never be one.
*/
function kindOf(item: Record<string, unknown>): DotBrowserItemKind {
if (item['type'] === 'folder') {
return 'folder';
}

if (item['type'] === 'link' || item['extension'] === 'link') {
return 'link';
}
Expand All @@ -695,9 +717,9 @@ function kindOf(item: Record<string, unknown>): DotBrowserItemKind {
/**
* Maps a picked row onto the published selection shape.
*
* `url` is the one guarantee: a contentlet reports `url` (or `urlMap`), a folder its path, a link
* its target. Contentlet-only fields are attached only for the kinds that actually have them, so a
* consumer can never read a mimetype off a folder.
* `url` is the one guarantee: a contentlet reports `url` (or `urlMap`), a link its target.
* Contentlet-only fields are attached only for the kinds that actually have them, so a consumer can
* never read a mimetype off a menu link.
*/
/**
* A string, or nothing.
Expand All @@ -721,7 +743,7 @@ function toSelection(item: DotCMSContentlet | Record<string, unknown>): DotBrows
url: String(row['url'] ?? row['urlMap'] ?? row['path'] ?? '')
};

if (kind === 'folder' || kind === 'link') {
if (kind === 'link') {
return base as DotBrowserSelection;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
* `DotCustomFieldApi.openBrowserModal()`.
*
* Replaces the previous `BrowserSelector*` shapes, which were named after the legacy
* `DotBrowserSelectorComponent` and modelled every selection as a contentlet — so a folder or a
* menu link had to carry a mimetype and a base type it does not have. That was safe to change
* because the API is unshipped: the templates that call it (`*_new.vtl`) only render when the new
* Edit Content is enabled, which is not the default.
* `DotBrowserSelectorComponent` and modelled every selection as a contentlet — so a menu link had
* to carry a mimetype and a base type it does not have. That was safe to change because the API is
* unshipped: the templates that call it (`*_new.vtl`) only render when the new Edit Content is
* enabled, which is not the default.
*
* The same unshipped status is why `'folder'` could be withdrawn outright in #37366 rather than
* deprecated: folders are navigation, reached through the picker's sidebar tree, and are neither
* listed nor returned.
*/

/**
Expand All @@ -15,8 +19,12 @@
* `file` and `dotasset` are kept apart rather than collapsed into one `asset` value because callers
* genuinely distinguish them — the shipped file-browser template asks for file assets while
* excluding dotAssets.
*
* `folder` is deliberately absent. Folders are navigation, not content: they appear only in the
* picker's sidebar tree, where selecting one changes what the list shows. No option lists a folder
* as a row and none returns one.
*/
export type DotBrowserItemKind = 'file' | 'dotasset' | 'page' | 'folder' | 'link';
export type DotBrowserItemKind = 'file' | 'dotasset' | 'page' | 'link';

/**
* What a caller asks the browser to show.
Expand All @@ -31,9 +39,20 @@ export interface DotBrowserOptions {
/**
* What may be listed and returned.
*
* One list rather than five booleans: `showFiles`/`showPages`/`showFolders`/`showLinks`/
* `showDotAssets` could express combinations that mean nothing, and could not say "these kinds"
* without naming every other kind too.
* One list rather than a boolean per kind: `showFiles`/`showPages`/`showLinks`/`showDotAssets`
* could express combinations that mean nothing, and could not say "these kinds" without naming
* every other kind too.
*
* **Folders cannot be requested and cannot be returned.** They are navigation, not content:
* they appear only in the picker's sidebar tree, where selecting one changes what the list
* shows. To store a folder path in a field, type it — the picker will not hand you one.
*
* An entry that is not a {@link DotBrowserItemKind} is **ignored with a console warning**
* rather than throwing, since a caller is a VTL `<script>` where an exception would break the
* whole custom field. `'folder'` is the case this exists for: the shipped file-browser template
* (`file_browser_field_render_new.vtl`) asked for it before the kind was withdrawn, and a
* third-party template still might. The other shipped caller, `redirect_custom_field_new.vtl`,
* never did.
*
* @default ['file', 'dotasset']
*/
Expand Down Expand Up @@ -107,8 +126,7 @@ export interface DotBrowserSelectionBase {
* The value a field stores. **Always non-empty.**
*
* The one field every shipped template actually reads, so an empty value is a defect rather
* than a degraded result: a contentlet's URL, a page's URL, a folder's path, or a link's
* target.
* than a degraded result: a contentlet's URL, a page's URL, or a link's target.
*/
url: string;
}
Expand All @@ -130,11 +148,6 @@ export interface DotBrowserPageSelection extends DotBrowserSelectionBase {
contentType?: string;
}

/** A folder. `url` is its path. */
export interface DotBrowserFolderSelection extends DotBrowserSelectionBase {
kind: 'folder';
}

/** A menu link. `url` is its target. */
export interface DotBrowserLinkSelection extends DotBrowserSelectionBase {
kind: 'link';
Expand All @@ -144,13 +157,12 @@ export interface DotBrowserLinkSelection extends DotBrowserSelectionBase {
* What the editor picked.
*
* A union discriminated by `kind` rather than one flat shape with everything optional: the flat
* shape let a consumer read a mimetype off a folder and get `undefined` with no indication that the
* question was meaningless. Branch on `kind` and each variant offers exactly the fields it has.
* shape let a consumer read a mimetype off a menu link and get `undefined` with no indication that
* the question was meaningless. Branch on `kind` and each variant offers exactly the fields it has.
*/
export type DotBrowserSelection =
| DotBrowserAssetSelection
| DotBrowserPageSelection
| DotBrowserFolderSelection
| DotBrowserLinkSelection;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ export interface FormBridge {
destroy(): void;

/**
* Opens the asset browser so the user can pick content — an asset, a page, a folder or a menu
* link.
* Opens the asset browser so the user can pick content — an asset, a page or a menu link.
*
* Folders are **not** pickable: they are navigation, reached through the browser's sidebar
* tree, and the list panel carries content only.
*
* Only the Angular host opens anything: the legacy Dojo editor has never had this dialog, and
* its bridge resolves `null` with a warning rather than pretending.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ describe('buildAssetPickerConfig', () => {
});

it('should carry the browse options through untouched', () => {
// No `showFolders` — the option left `DotAssetPickerBrowseOptions` in #37366. The
// picker's list carries content only, so there is nothing for a caller to opt into.
const browse = {
showFolders: true,
showLinks: true,
showWorking: false,
showArchived: false,
Expand All @@ -238,6 +239,16 @@ describe('buildAssetPickerConfig', () => {
expect(config.browse).toEqual(browse);
});

it('should never carry a folder browse option', () => {
const config = buildAssetPickerConfig({
mode: 'browse',
site: SITE,
browse: { showLinks: true }
});

expect(config.browse).not.toHaveProperty('showFolders');
});

it('should use the caller-supplied title', () => {
// The picker renders its own header, so the title travels in the config rather than in
// `DynamicDialogConfig.header`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export const DEFAULT_ASSET_PICKER_SORT: DotAssetPickerSort = {
export const DEFAULT_ASSET_PICKER_PAGE: DotAssetPickerPage = {
contentCursor: 0,
hasMoreContent: true,
folderCursor: 0,
hasMoreFolders: true,
linkCursor: 0,
hasMoreLinks: true
};
Expand Down
Loading
Loading