From f854db42500d5b7809b2675d4eb87af0cace2571 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 6 Oct 2026 10:30:45 +0200 Subject: [PATCH 1/5] feat(metadata-sidebar): Add search to namespaced templates --- src/api/Metadata.js | 8 + src/api/MetadataNamespaces.js | 27 +++ src/api/__tests__/MetadataNamespaces.test.js | 44 +++++ .../useMetadataTemplateItemsService.test.tsx | 154 +++++++++++++--- .../hooks/useMetadataTemplateItemsService.ts | 164 ++++++++++++++---- 5 files changed, 340 insertions(+), 57 deletions(-) diff --git a/src/api/Metadata.js b/src/api/Metadata.js index bf255849bd..758a03e339 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -328,6 +328,14 @@ class Metadata extends File { return this.getNamespacesAPI().listTemplatesForNamespace(file, namespaceFqn, params); } + /** @see MetadataNamespaces.searchTemplates */ + searchTemplates( + file: BoxItem, + params: { query: string, limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + return this.getNamespacesAPI().searchTemplates(file, params); + } + /** @see MetadataNamespaces.createMetadataTemplate */ createMetadataTemplate( file: BoxItem, diff --git a/src/api/MetadataNamespaces.js b/src/api/MetadataNamespaces.js index 64af59b710..7bea44fa01 100644 --- a/src/api/MetadataNamespaces.js +++ b/src/api/MetadataNamespaces.js @@ -77,6 +77,33 @@ export default class MetadataNamespaces { } } + /** + * Typeahead over templates the caller can see. + * + * `GET /metadata_templates/search`. `query` is a required prefix + * matched against `displayName` and `templateKey`. Hits omit template fields + * and hidden templates. A sub-namespace hit carries `containingNamespace` + * and `ancestors` (`fqn` + `displayName` only); enterprise-root, legacy, and + * global hits carry neither. The path is `ancestors` and `containingNamespace`. + * Errors propagate so the picker can show its search error state. + */ + async searchTemplates( + file: BoxItem, + params: { query: string, limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + const url = `${this.host.getMetadataTemplateUrl()}/search`; + const response = await this.host.xhr.get({ + url, + id: this.getRequestFileId(file), + params: { + query: params.query, + limit: params.limit, + ...(params.marker ? { marker: params.marker } : {}), + }, + }); + return getProp(response, 'data', { entries: [] }); + } + /** * Lists templates under a namespace FQN with cursor pagination. */ diff --git a/src/api/__tests__/MetadataNamespaces.test.js b/src/api/__tests__/MetadataNamespaces.test.js index 902e89b645..527b4647f1 100644 --- a/src/api/__tests__/MetadataNamespaces.test.js +++ b/src/api/__tests__/MetadataNamespaces.test.js @@ -119,6 +119,50 @@ describe('api/MetadataNamespaces', () => { }); }); + describe('searchTemplates()', () => { + test('should GET /metadata_templates/search with query, limit, and marker', async () => { + host.xhr.get.mockResolvedValue({ + data: { + limit: 20, + next_marker: 'cursor-1', + prev_marker: null, + entries: [], + }, + }); + + await expect(api.searchTemplates(file, { query: 'Le', limit: 20, marker: 'cursor-0' })).resolves.toEqual({ + limit: 20, + next_marker: 'cursor-1', + prev_marker: null, + entries: [], + }); + expect(host.xhr.get).toHaveBeenCalledWith({ + url: 'https://api.box.com/2.0/metadata_templates/search', + id: 'file_123', + params: { query: 'Le', limit: 20, marker: 'cursor-0' }, + }); + }); + + test('should omit marker when the caller has no cursor', async () => { + host.xhr.get.mockResolvedValue({ data: { entries: [] } }); + + await api.searchTemplates(file, { query: 'Le', limit: 20 }); + + expect(host.xhr.get).toHaveBeenCalledWith({ + url: 'https://api.box.com/2.0/metadata_templates/search', + id: 'file_123', + params: { query: 'Le', limit: 20 }, + }); + }); + + test('should propagate a failed search', async () => { + const error = new Error('not found'); + host.xhr.get.mockRejectedValue(error); + + await expect(api.searchTemplates(file, { query: 'Le', limit: 20 })).rejects.toBe(error); + }); + }); + describe('getTemplateSchemaForEditor()', () => { test('should map hidden from the live schema response', async () => { host.metadataNamespaceMode = METADATA_SCOPE_MODE_MIGRATION; diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx index 28e8a1f6e6..be49be4b3a 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -32,15 +32,18 @@ describe('useMetadataTemplateItemsService', () => { let listNamespaces: jest.Mock; let listTemplatesForNamespace: jest.Mock; + let searchTemplates: jest.Mock; let api: { getMetadataAPI: jest.Mock }; beforeEach(() => { listNamespaces = jest.fn().mockResolvedValue({ entries: [], next_marker: undefined }); listTemplatesForNamespace = jest.fn().mockResolvedValue({ entries: [], next_marker: undefined }); + searchTemplates = jest.fn().mockResolvedValue({ entries: [], next_marker: undefined }); api = { getMetadataAPI: jest.fn().mockReturnValue({ listNamespaces, listTemplatesForNamespace, + searchTemplates, }), }; }); @@ -59,7 +62,7 @@ describe('useMetadataTemplateItemsService', () => { test('should delegate getNamespaces to the metadata API', async () => { const namespacesResponse = { - entries: [{ id: 'ns-1', fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], + entries: [{ fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], next_marker: 'marker-1', }; listNamespaces.mockResolvedValue(namespacesResponse); @@ -68,9 +71,10 @@ describe('useMetadataTemplateItemsService', () => { useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), ); - await expect(result.current!.getNamespaces(enterpriseFqn, { limit: 20, marker: 'm0' })).resolves.toEqual( - namespacesResponse, - ); + await expect(result.current!.getNamespaces(enterpriseFqn, { limit: 20, marker: 'm0' })).resolves.toEqual({ + entries: [{ fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], + next_marker: 'marker-1', + }); expect(api.getMetadataAPI).toHaveBeenCalledWith(false); expect(listNamespaces).toHaveBeenCalledWith(mockFile, enterpriseFqn, { limit: 20, marker: 'm0' }); }); @@ -157,47 +161,147 @@ describe('useMetadataTemplateItemsService', () => { }); }); - test('should filter and paginate getSearchResults client-side', async () => { - const manyTemplates = Array.from({ length: 5 }, (_, index) => ({ - id: `id-${index}`, - templateKey: `key-${index}`, - scope: enterpriseFqn, - type: 'metadata_template', - displayName: index % 2 === 0 ? `Alpha ${index}` : `Beta ${index}`, - canEdit: true, - hidden: false, - fields: [], - })); + test('should search templates through the metadata API and map the hit shape', async () => { + searchTemplates.mockResolvedValue({ + entries: [ + { + type: 'metadata_template', + id: 'api-legal-hold', + templateKey: 'legalHold', + displayName: 'Legal Hold', + namespace: `${enterpriseFqn}.legal.contracts`, + containingNamespace: { fqn: `${enterpriseFqn}.legal.contracts`, displayName: 'Contracts' }, + ancestors: [ + { fqn: enterpriseFqn, displayName: 'Enterprise' }, + { fqn: `${enterpriseFqn}.legal`, displayName: 'Legal' }, + ], + }, + { + type: 'metadata_template', + id: 'api-my-template', + templateKey: 'myTemplate', + displayName: 'My Template', + namespace: enterpriseFqn, + }, + { + type: 'metadata_template', + id: 'api-global', + templateKey: 'legalContract', + displayName: 'Legal Contract', + namespace: 'box.metadata', + }, + { + type: 'metadata_template', + id: 'api-hidden', + templateKey: 'hiddenTemplate', + displayName: 'Hidden', + namespace: enterpriseFqn, + hidden: true, + }, + ], + next_marker: null, + }); const { result } = renderHook(() => - useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, manyTemplates as never), + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), ); - await expect(result.current!.getSearchResults('alpha', { limit: 2, marker: undefined })).resolves.toEqual({ + await expect(result.current!.getSearchResults(' Le ', { limit: 20, marker: 'cursor-0' })).resolves.toEqual({ entries: [ - expect.objectContaining({ displayName: 'Alpha 0' }), - expect.objectContaining({ displayName: 'Alpha 2' }), + { + id: `${enterpriseFqn}.legal.contracts||legalHold`, + type: 'metadata_template', + displayName: 'Legal Hold', + scope: `${enterpriseFqn}.legal.contracts`, + namespace: `${enterpriseFqn}.legal.contracts`, + templateKey: 'legalHold', + canEdit: true, + hidden: false, + ancestors: [ + { fqn: enterpriseFqn, displayName: 'Enterprise' }, + { fqn: `${enterpriseFqn}.legal`, displayName: 'Legal' }, + { fqn: `${enterpriseFqn}.legal.contracts`, displayName: 'Contracts' }, + ], + }, + { + id: 'editor-1', + type: 'metadata_template', + displayName: 'My Template', + scope: enterpriseFqn, + namespace: enterpriseFqn, + templateKey: 'myTemplate', + canEdit: true, + hidden: false, + ancestors: undefined, + }, + { + id: 'box.metadata||legalContract', + type: 'metadata_template', + displayName: 'Legal Contract', + scope: 'box.metadata', + namespace: 'box.metadata', + templateKey: 'legalContract', + canEdit: false, + hidden: false, + ancestors: undefined, + }, ], - next_marker: '2', - }); - - await expect(result.current!.getSearchResults('alpha', { limit: 2, marker: '2' })).resolves.toEqual({ - entries: [expect.objectContaining({ displayName: 'Alpha 4' })], next_marker: undefined, }); + expect(searchTemplates).toHaveBeenCalledWith(mockFile, { query: 'Le', limit: 20, marker: 'cursor-0' }); + }); + + test('should not call search for a blank query', async () => { + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + await expect(result.current!.getSearchResults(' ', { limit: 20 })).resolves.toEqual({ entries: [] }); + expect(searchTemplates).not.toHaveBeenCalled(); }); test('should use the localized custom metadata name for properties templates in search', async () => { + searchTemplates.mockResolvedValue({ + entries: [ + { + id: 'api-props', + templateKey: METADATA_TEMPLATE_PROPERTIES, + scope: 'global', + type: 'metadata_template', + displayName: 'Properties', + }, + ], + next_marker: undefined, + }); + const { result } = renderHook(() => useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), ); await expect(result.current!.getSearchResults('custom', { limit: 10, marker: undefined })).resolves.toEqual({ - entries: [expect.objectContaining({ id: 'editor-props', displayName: 'Custom Metadata', canEdit: false })], + entries: [ + expect.objectContaining({ + id: 'editor-props', + displayName: 'Custom Metadata', + canEdit: false, + scope: 'global', + }), + ], next_marker: undefined, }); }); + test('should propagate a failed template search', async () => { + const error = new Error('search unavailable'); + searchTemplates.mockRejectedValue(error); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + await expect(result.current!.getSearchResults('Le', { limit: 20 })).rejects.toBe(error); + }); + test('should merge already-loaded enterprise templates when the namespace list is empty', async () => { const { result } = renderHook(() => useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 66e566d90f..7288aebe7c 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -10,11 +10,13 @@ import { import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; import API from '../../../api'; -import { METADATA_SCOPE_GLOBAL, METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import { METADATA_NAMESPACE_GLOBAL, METADATA_SCOPE_GLOBAL, METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; import messages from '../../../features/metadata-instance-editor/messages'; import type { BoxItem } from '../../../common/types/core'; import { getMetadataTemplateNamespaceFqn, isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; +type BreadcrumbEntry = NonNullable[number]; + function resolveDisplayName(template: EditorMetadataTemplate, customMetadataName: string): string { if (template.templateKey === METADATA_TEMPLATE_PROPERTIES) { return customMetadataName; @@ -35,18 +37,92 @@ function canEditMetadataTemplate(templateKey?: string, scopeOrNamespace?: string if (!templateKey || templateKey === METADATA_TEMPLATE_PROPERTIES) { return false; } - return scopeOrNamespace !== METADATA_SCOPE_GLOBAL && scopeOrNamespace !== 'global'; + return ( + scopeOrNamespace !== METADATA_SCOPE_GLOBAL && + scopeOrNamespace !== 'global' && + scopeOrNamespace !== METADATA_NAMESPACE_GLOBAL + ); +} + +function readNonBlank(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +type NamespaceFields = { + displayName?: unknown; + display_name?: unknown; + fqn?: unknown; +}; + +function asNamespaceFields(value: unknown): NamespaceFields | undefined { + if (value && typeof value === 'object') { + return value as NamespaceFields; + } + return undefined; +} + +function toBreadcrumbEntry(value: unknown): BreadcrumbEntry | undefined { + const fields = asNamespaceFields(value); + if (!fields) { + return undefined; + } + + const fqn = readNonBlank(fields.fqn); + if (!fqn) { + return undefined; + } + + const dot = fqn.lastIndexOf('.'); + const leaf = dot === -1 ? fqn : fqn.slice(dot + 1); + + return { + fqn, + displayName: readNonBlank(fields.displayName) || readNonBlank(fields.display_name) || leaf, + }; +} + +/** + * Search-row subtitle. The folder path is `ancestors` plus the immediate + * folder in `containingNamespace`. + * Root, legacy, and global hits omit both, so they render with no path. + * `containingNamespace` is appended because `ancestors` stops at the parent. + */ +function toSearchAncestors(entry: Record): BreadcrumbEntry[] | undefined { + const rawAncestors = Array.isArray(entry.ancestors) ? entry.ancestors : []; + const path = rawAncestors + .map(ancestor => toBreadcrumbEntry(ancestor)) + .filter((ancestor): ancestor is BreadcrumbEntry => ancestor !== undefined); + + const containing = toBreadcrumbEntry(entry.containingNamespace); + if (containing && !path.some(ancestor => ancestor.fqn === containing.fqn)) { + path.push(containing); + } + + return path.length > 0 ? path : undefined; } +function resolveSearchDisplayName( + hit: Record, + templateKey: string | undefined, + editorMatch: EditorMetadataTemplate | undefined, + customMetadataName: string, +): string { + if (editorMatch) { + return resolveDisplayName(editorMatch, customMetadataName); + } + if (templateKey === METADATA_TEMPLATE_PROPERTIES) { + return customMetadataName; + } + return readNonBlank(hit.displayName) || templateKey || ''; +} /** * Builds the data-fetching `ItemsService` consumed by `MetadataTemplateBrowser` * for the metadata sidebar in namespace-enabled mode. * * - `getNamespaces` and `getTemplates` delegate to live API calls via `Metadata.js`, * enabling paginated namespace navigation and per-namespace template lists. - * - `getSearchResults` performs client-side filtering over the editor-shape `templates` - * already fetched by `useSidebarMetadataFetcher`. A server-side search endpoint - * would replace this body when available. + * - `getSearchResults` calls `GET /metadata_templates/search`. The browser + * already invokes this callback as the user types; this supplies the endpoint. * * Returns `undefined` when `enterpriseFqn` is not yet known (current user still loading). * @@ -67,20 +143,6 @@ export default function useMetadataTemplateItemsService( return undefined; } - // Flat browser-shape list derived from the already-loaded editor templates. - // Used for client-side search so search doesn't require a round-trip. - const browserTemplatesForSearch: BrowserMetadataTemplate[] = templates - .filter(t => !isHiddenTemplate(t)) - .map(t => ({ - id: t.id, - type: t.type, - displayName: resolveDisplayName(t, customMetadataName), - scope: t.scope, - templateKey: t.templateKey, - canEdit: canEditMetadataTemplate(t.templateKey, getMetadataTemplateNamespaceFqn(t)), - hidden: t.hidden, - })); - return { getNamespaces: async ( namespaceFQN: string, @@ -89,7 +151,13 @@ export default function useMetadataTemplateItemsService( const result = await api .getMetadataAPI(false) .listNamespaces(file, namespaceFQN, { limit: params.limit, marker: params.marker }); - return result as FetchResponse; + return { + entries: (result.entries ?? []).map(entry => { + const namespace = entry as { displayName: string; fqn: string }; + return { displayName: namespace.displayName, fqn: namespace.fqn }; + }), + next_marker: readNonBlank(result.next_marker), + }; }, getTemplates: async ( @@ -158,18 +226,50 @@ export default function useMetadataTemplateItemsService( query: string, params: FetchParams, ): Promise> => { - const normalizedQuery = query.trim().toLowerCase(); - const filtered = normalizedQuery - ? browserTemplatesForSearch.filter(t => t.displayName.toLowerCase().includes(normalizedQuery)) - : browserTemplatesForSearch; - - // Cursor pagination over in-memory results using numeric offset markers. - const start = params.marker ? Number.parseInt(params.marker, 10) : 0; - const end = start + params.limit; - return { - entries: filtered.slice(start, end), - next_marker: end < filtered.length ? String(end) : undefined, - }; + const normalizedQuery = query.trim(); + // The search endpoint 400s on a missing or blank query. An empty + // string is the browser leaving search, not a request to send. + if (!normalizedQuery) { + return { entries: [] }; + } + + const result = await api.getMetadataAPI(false).searchTemplates(file, { + query: normalizedQuery, + limit: params.limit, + marker: params.marker, + }); + + const entries: BrowserMetadataTemplate[] = (result.entries ?? []) + .filter((hit: Record) => !isHiddenTemplate(hit)) + .map((hit: Record) => { + const templateKey = readNonBlank(hit.templateKey); + const namespace = readNonBlank(hit.namespace); + const scope = readNonBlank(hit.scope) ?? namespace; + const namespaceFqn = namespace ?? scope; + const editorMatch = templates.find(template => + isSameMetadataTemplate(template, { templateKey, namespace, scope }), + ); + + return { + // Same id encoding as getTemplates: a raw API id cannot + // recover namespace and key for a child-namespace hit. + id: + editorMatch?.id ?? + (namespaceFqn && templateKey + ? `${namespaceFqn}||${templateKey}` + : readNonBlank(hit.id) || ''), + type: readNonBlank(hit.type) ?? 'metadata_template', + displayName: resolveSearchDisplayName(hit, templateKey, editorMatch, customMetadataName), + scope, + namespace, + templateKey, + canEdit: canEditMetadataTemplate(templateKey, namespaceFqn), + hidden: false, + ancestors: toSearchAncestors(hit), + }; + }); + + return { entries, next_marker: readNonBlank(result.next_marker) }; }, }; }, [api, file, enterpriseFqn, templates, customMetadataName]); From 9fa82ed3c22cc74868e485edcf154f06fb7533f6 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 6 Oct 2026 16:20:54 +0200 Subject: [PATCH 2/5] feat(metadata-sidebar): Gate template create and edit on API permissions Request fields=permissions and show those controls only when the namespace or template grant allows them, including at the enterprise root and in search. --- src/api/Metadata.js | 5 ++ src/api/MetadataNamespaces.js | 29 ++++++- src/api/__tests__/MetadataNamespaces.test.js | 35 ++++++++- .../MetadataSidebarRedesign.tsx | 11 ++- .../useCanCreateTemplateAtRoot.test.tsx | 67 ++++++++++++++++ .../useMetadataTemplateItemsService.test.tsx | 78 ++++++++++++++++++- .../hooks/useCanCreateTemplateAtRoot.ts | 48 ++++++++++++ .../hooks/useMetadataTemplateItemsService.ts | 26 +++---- .../metadataTemplatePermissions.test.ts | 55 +++++++++++++ .../utils/metadataTemplatePermissions.ts | 58 ++++++++++++++ 10 files changed, 387 insertions(+), 25 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/useCanCreateTemplateAtRoot.test.tsx create mode 100644 src/elements/content-sidebar/hooks/useCanCreateTemplateAtRoot.ts create mode 100644 src/elements/content-sidebar/utils/__tests__/metadataTemplatePermissions.test.ts create mode 100644 src/elements/content-sidebar/utils/metadataTemplatePermissions.ts diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 758a03e339..35908462c4 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -310,6 +310,11 @@ class Metadata extends File { return this.getNamespacesAPI().getMetadataNamespacesUrl(namespaceFqn); } + /** @see MetadataNamespaces.getNamespace */ + getNamespace(file: BoxItem, namespaceFqn: string): Promise { + return this.getNamespacesAPI().getNamespace(file, namespaceFqn); + } + /** @see MetadataNamespaces.listNamespaces */ listNamespaces( file: BoxItem, diff --git a/src/api/MetadataNamespaces.js b/src/api/MetadataNamespaces.js index 7bea44fa01..eaf4dcc8a6 100644 --- a/src/api/MetadataNamespaces.js +++ b/src/api/MetadataNamespaces.js @@ -69,7 +69,7 @@ export default class MetadataNamespaces { const response = await this.host.xhr.get({ url, id: this.getRequestFileId(file), - params: { limit: params.limit, marker: params.marker }, + params: { limit: params.limit, marker: params.marker, fields: 'permissions' }, }); return getProp(response, 'data', { entries: [] }); } catch (e) { @@ -77,6 +77,28 @@ export default class MetadataNamespaces { } } + /** + * Fetches one namespace, including `permissions` when the caller may create + * templates under it. + * + * The enterprise root is never a row of `.../children`, so template + * creation at the root is decided from this response. `permissions` is + * omitted unless `fields=permissions` is requested. + */ + async getNamespace(file: BoxItem, namespaceFqn: string): Promise { + const url = this.getMetadataNamespacesUrl(namespaceFqn); + try { + const response = await this.host.xhr.get({ + url, + id: this.getRequestFileId(file), + params: { fields: 'permissions' }, + }); + return getProp(response, 'data', null); + } catch (e) { + return null; + } + } + /** * Typeahead over templates the caller can see. * @@ -85,6 +107,8 @@ export default class MetadataNamespaces { * and hidden templates. A sub-namespace hit carries `containingNamespace` * and `ancestors` (`fqn` + `displayName` only); enterprise-root, legacy, and * global hits carry neither. The path is `ancestors` and `containingNamespace`. + * `fields=permissions` asks for the same template permission object as + * template list. The field is absent until that search response includes it. * Errors propagate so the picker can show its search error state. */ async searchTemplates( @@ -98,6 +122,7 @@ export default class MetadataNamespaces { params: { query: params.query, limit: params.limit, + fields: 'permissions', ...(params.marker ? { marker: params.marker } : {}), }, }); @@ -117,7 +142,7 @@ export default class MetadataNamespaces { const response = await this.host.xhr.get({ url, id: this.getRequestFileId(file), - params: { limit: params.limit, marker: params.marker }, + params: { limit: params.limit, marker: params.marker, fields: 'permissions' }, }); return getProp(response, 'data', { entries: [] }); } catch (e) { diff --git a/src/api/__tests__/MetadataNamespaces.test.js b/src/api/__tests__/MetadataNamespaces.test.js index 527b4647f1..e480e72566 100644 --- a/src/api/__tests__/MetadataNamespaces.test.js +++ b/src/api/__tests__/MetadataNamespaces.test.js @@ -114,11 +114,40 @@ describe('api/MetadataNamespaces', () => { expect(host.xhr.get).toHaveBeenCalledWith({ url: 'https://api.box.com/2.0/metadata_namespaces/enterprise_1/children', id: 'file_123', - params: { limit: 20, marker: undefined }, + params: { limit: 20, marker: undefined, fields: 'permissions' }, }); }); }); + describe('getNamespace()', () => { + test('should GET the namespace and return its grants', async () => { + host.xhr.get.mockResolvedValue({ + data: { + fqn: 'enterprise_1', + displayName: 'Enterprise', + permissions: { can_create_templates: true }, + }, + }); + + await expect(api.getNamespace(file, 'enterprise_1')).resolves.toEqual({ + fqn: 'enterprise_1', + displayName: 'Enterprise', + permissions: { can_create_templates: true }, + }); + expect(host.xhr.get).toHaveBeenCalledWith({ + url: 'https://api.box.com/2.0/metadata_namespaces/enterprise_1', + id: 'file_123', + params: { fields: 'permissions' }, + }); + }); + + test('should return null when the namespace request fails', async () => { + host.xhr.get.mockRejectedValue(new Error('not found')); + + await expect(api.getNamespace(file, 'enterprise_1')).resolves.toBeNull(); + }); + }); + describe('searchTemplates()', () => { test('should GET /metadata_templates/search with query, limit, and marker', async () => { host.xhr.get.mockResolvedValue({ @@ -139,7 +168,7 @@ describe('api/MetadataNamespaces', () => { expect(host.xhr.get).toHaveBeenCalledWith({ url: 'https://api.box.com/2.0/metadata_templates/search', id: 'file_123', - params: { query: 'Le', limit: 20, marker: 'cursor-0' }, + params: { query: 'Le', limit: 20, fields: 'permissions', marker: 'cursor-0' }, }); }); @@ -151,7 +180,7 @@ describe('api/MetadataNamespaces', () => { expect(host.xhr.get).toHaveBeenCalledWith({ url: 'https://api.box.com/2.0/metadata_templates/search', id: 'file_123', - params: { query: 'Le', limit: 20 }, + params: { query: 'Le', limit: 20, fields: 'permissions' }, }); }); diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 8fbaea3669..439311b3ed 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -67,6 +67,7 @@ import { useMetadataSidebarFilteredTemplates } from './hooks/useMetadataSidebarF import useMetadataFieldSelection from './hooks/useMetadataFieldSelection'; import useMetadataSidebarUnsavedChangesGuard from './hooks/useMetadataSidebarUnsavedChangesGuard'; import useMetadataTemplateEditor from './hooks/useMetadataTemplateEditor'; +import useCanCreateTemplateAtRoot from './hooks/useCanCreateTemplateAtRoot'; import useMetadataTemplateItemsService from './hooks/useMetadataTemplateItemsService'; import { type MetadataTemplateLocator } from './hooks/useMetadataTemplateEventService'; import useMetadataNamespaceContext, { type MetadataScopeMode } from './hooks/useMetadataNamespaceContext'; @@ -235,6 +236,13 @@ function MetadataSidebarRedesign({ isTemplateManagementEnabled ? enterpriseId : undefined, templates ?? [], ); + // The browser reads this only when it mounts, so the dropdown is keyed on + // the resolved value and remounts once the root namespace grant arrives. + const canCreateAtRoot = useCanCreateTemplateAtRoot( + api, + file, + isTemplateManagementEnabled ? enterpriseId : undefined, + ); const { handleUnsavedChangesModalOpen, pendingNavLocation, setPendingNavLocation, unblockRouterHistory } = useMetadataSidebarUnsavedChangesGuard({ @@ -510,6 +518,7 @@ function MetadataSidebarRedesign({ const metadataDropdown = canEdit && isSuccess && templates && ( diff --git a/src/elements/content-sidebar/__tests__/useCanCreateTemplateAtRoot.test.tsx b/src/elements/content-sidebar/__tests__/useCanCreateTemplateAtRoot.test.tsx new file mode 100644 index 0000000000..2de581539c --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useCanCreateTemplateAtRoot.test.tsx @@ -0,0 +1,67 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useCanCreateTemplateAtRoot from '../hooks/useCanCreateTemplateAtRoot'; + +describe('useCanCreateTemplateAtRoot', () => { + const file = { id: 'file-123' }; + const enterpriseFqn = 'enterprise_1'; + + const renderCreateHook = (getNamespace: jest.Mock, fqn: string | undefined = enterpriseFqn) => { + const api = { getMetadataAPI: jest.fn().mockReturnValue({ getNamespace }) }; + return renderHook(() => useCanCreateTemplateAtRoot(api as never, file as never, fqn)); + }; + + test('should not request a namespace when the enterprise root is unknown', () => { + const getNamespace = jest.fn(); + const api = { getMetadataAPI: jest.fn().mockReturnValue({ getNamespace }) }; + const { result } = renderHook(() => useCanCreateTemplateAtRoot(api as never, file as never, undefined)); + + expect(result.current).toBe(false); + expect(getNamespace).not.toHaveBeenCalled(); + }); + + test('should allow creation when the root namespace grants it', async () => { + const getNamespace = jest.fn().mockResolvedValue({ + fqn: enterpriseFqn, + permissions: { + can_read: true, + can_update: false, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + can_create_namespaces: false, + can_create_templates: true, + }, + }); + const { result } = renderCreateHook(getNamespace); + + await waitFor(() => expect(result.current).toBe(true)); + expect(getNamespace).toHaveBeenCalledWith(file, enterpriseFqn); + }); + + test('should deny creation when the root namespace does not grant it', async () => { + const getNamespace = jest.fn().mockResolvedValue({ + fqn: enterpriseFqn, + permissions: { + can_read: true, + can_update: false, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + can_create_namespaces: false, + can_create_templates: false, + }, + }); + const { result } = renderCreateHook(getNamespace); + + await waitFor(() => expect(getNamespace).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + test('should deny creation when the namespace request fails', async () => { + const getNamespace = jest.fn().mockRejectedValue(new Error('unavailable')); + const { result } = renderCreateHook(getNamespace); + + await waitFor(() => expect(getNamespace).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx index be49be4b3a..90f3e03bc2 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -16,6 +16,13 @@ describe('useMetadataTemplateItemsService', () => { displayName: 'My Template', canEdit: true, hidden: false, + permissions: { + can_read: true, + can_update: true, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + }, fields: [], }, { @@ -62,7 +69,34 @@ describe('useMetadataTemplateItemsService', () => { test('should delegate getNamespaces to the metadata API', async () => { const namespacesResponse = { - entries: [{ fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], + entries: [ + { + fqn: `${enterpriseFqn}.child`, + displayName: 'Child', + permissions: { + can_read: true, + can_update: false, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + can_create_namespaces: false, + can_create_templates: true, + }, + }, + { + fqn: `${enterpriseFqn}.readonly`, + displayName: 'Read only', + permissions: { + can_read: true, + can_update: false, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + can_create_namespaces: false, + can_create_templates: false, + }, + }, + ], next_marker: 'marker-1', }; listNamespaces.mockResolvedValue(namespacesResponse); @@ -72,7 +106,10 @@ describe('useMetadataTemplateItemsService', () => { ); await expect(result.current!.getNamespaces(enterpriseFqn, { limit: 20, marker: 'm0' })).resolves.toEqual({ - entries: [{ fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], + entries: [ + { fqn: `${enterpriseFqn}.child`, displayName: 'Child', canCreate: true }, + { fqn: `${enterpriseFqn}.readonly`, displayName: 'Read only', canCreate: false }, + ], next_marker: 'marker-1', }); expect(api.getMetadataAPI).toHaveBeenCalledWith(false); @@ -87,6 +124,13 @@ describe('useMetadataTemplateItemsService', () => { templateKey: 'visibleTemplate', namespace: `${enterpriseFqn}.legal`, displayName: 'Visible', + permissions: { + can_read: true, + can_update: true, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + }, }, { id: 'api-id-2', @@ -121,12 +165,26 @@ describe('useMetadataTemplateItemsService', () => { displayName: 'My Template', canEdit: true, hidden: false, + permissions: { + can_read: true, + can_update: true, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + }, }, { id: 'api-id-2', templateKey: 'childOnly', namespace: `${enterpriseFqn}.child`, displayName: 'Child Only', + permissions: { + can_read: true, + can_update: false, + can_delete: true, + can_view_permissions: false, + can_manage_permissions: false, + }, }, ], next_marker: undefined, @@ -153,7 +211,7 @@ describe('useMetadataTemplateItemsService', () => { displayName: 'Child Only', scope: `${enterpriseFqn}.child`, templateKey: 'childOnly', - canEdit: true, + canEdit: false, hidden: false, }, ], @@ -175,6 +233,13 @@ describe('useMetadataTemplateItemsService', () => { { fqn: enterpriseFqn, displayName: 'Enterprise' }, { fqn: `${enterpriseFqn}.legal`, displayName: 'Legal' }, ], + permissions: { + can_read: true, + can_update: true, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + }, }, { type: 'metadata_template', @@ -182,6 +247,13 @@ describe('useMetadataTemplateItemsService', () => { templateKey: 'myTemplate', displayName: 'My Template', namespace: enterpriseFqn, + permissions: { + can_read: true, + can_update: true, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, + }, }, { type: 'metadata_template', diff --git a/src/elements/content-sidebar/hooks/useCanCreateTemplateAtRoot.ts b/src/elements/content-sidebar/hooks/useCanCreateTemplateAtRoot.ts new file mode 100644 index 0000000000..ca5d9fa7d9 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useCanCreateTemplateAtRoot.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; + +import API from '../../../api'; +import type { BoxItem } from '../../../common/types/core'; +import { canCreateTemplatesInNamespace } from '../utils/metadataTemplatePermissions'; + +/** + * Whether the enterprise root namespace allows template creation. + * + * The root is never a row in `GET /metadata_namespaces/{fqn}/children`, so its + * `CREATE_NAMESPACE_TEMPLATES` grant comes from `GET /metadata_namespaces/{fqn}`. + * Stays false until that response arrives, and on any failure. + */ +export default function useCanCreateTemplateAtRoot( + api: API, + file: BoxItem | null, + enterpriseFqn: string | undefined, +): boolean { + const [canCreateAtRoot, setCanCreateAtRoot] = useState(false); + + useEffect(() => { + if (!file?.id || !enterpriseFqn) { + setCanCreateAtRoot(false); + return undefined; + } + + let cancelled = false; + + api.getMetadataAPI(false) + .getNamespace(file, enterpriseFqn) + .then(namespace => { + if (!cancelled) { + setCanCreateAtRoot(canCreateTemplatesInNamespace(namespace)); + } + }) + .catch(() => { + if (!cancelled) { + setCanCreateAtRoot(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api, enterpriseFqn, file]); + + return canCreateAtRoot; +} diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 7288aebe7c..10b1f932af 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -10,10 +10,11 @@ import { import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; import API from '../../../api'; -import { METADATA_NAMESPACE_GLOBAL, METADATA_SCOPE_GLOBAL, METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; import messages from '../../../features/metadata-instance-editor/messages'; import type { BoxItem } from '../../../common/types/core'; import { getMetadataTemplateNamespaceFqn, isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; +import { canCreateTemplatesInNamespace, canEditMetadataTemplate } from '../utils/metadataTemplatePermissions'; type BreadcrumbEntry = NonNullable[number]; @@ -33,17 +34,6 @@ function isHiddenTemplate(template: { hidden?: unknown; isHidden?: unknown }): b return template.hidden === true || template.isHidden === true; } -function canEditMetadataTemplate(templateKey?: string, scopeOrNamespace?: string): boolean { - if (!templateKey || templateKey === METADATA_TEMPLATE_PROPERTIES) { - return false; - } - return ( - scopeOrNamespace !== METADATA_SCOPE_GLOBAL && - scopeOrNamespace !== 'global' && - scopeOrNamespace !== METADATA_NAMESPACE_GLOBAL - ); -} - function readNonBlank(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } @@ -154,7 +144,11 @@ export default function useMetadataTemplateItemsService( return { entries: (result.entries ?? []).map(entry => { const namespace = entry as { displayName: string; fqn: string }; - return { displayName: namespace.displayName, fqn: namespace.fqn }; + return { + displayName: namespace.displayName, + fqn: namespace.fqn, + canCreate: canCreateTemplatesInNamespace(entry), + }; }), next_marker: readNonBlank(result.next_marker), }; @@ -191,7 +185,7 @@ export default function useMetadataTemplateItemsService( displayName: ((t.displayName as string) ?? templateKey) || '', scope: templateScope, templateKey, - canEdit: canEditMetadataTemplate(templateKey, templateScope), + canEdit: canEditMetadataTemplate(t), hidden: false, }; }); @@ -215,7 +209,7 @@ export default function useMetadataTemplateItemsService( displayName: resolveDisplayName(t, customMetadataName), scope: getMetadataTemplateNamespaceFqn(t) ?? namespaceFQN, templateKey: t.templateKey, - canEdit: canEditMetadataTemplate(t.templateKey, getMetadataTemplateNamespaceFqn(t)), + canEdit: canEditMetadataTemplate(t), hidden: t.hidden ?? false, })); @@ -263,7 +257,7 @@ export default function useMetadataTemplateItemsService( scope, namespace, templateKey, - canEdit: canEditMetadataTemplate(templateKey, namespaceFqn), + canEdit: canEditMetadataTemplate(hit), hidden: false, ancestors: toSearchAncestors(hit), }; diff --git a/src/elements/content-sidebar/utils/__tests__/metadataTemplatePermissions.test.ts b/src/elements/content-sidebar/utils/__tests__/metadataTemplatePermissions.test.ts new file mode 100644 index 0000000000..b4c28cc149 --- /dev/null +++ b/src/elements/content-sidebar/utils/__tests__/metadataTemplatePermissions.test.ts @@ -0,0 +1,55 @@ +import { canCreateTemplatesInNamespace, canEditMetadataTemplate } from '../metadataTemplatePermissions'; + +const templatePermissions = { + can_read: false, + can_update: false, + can_delete: false, + can_view_permissions: false, + can_manage_permissions: false, +}; + +const namespacePermissions = { + ...templatePermissions, + can_create_namespaces: false, + can_create_templates: false, +}; + +describe('metadataTemplatePermissions', () => { + test('should allow template creation only when every namespace flag is boolean and can_create_templates is true', () => { + expect( + canCreateTemplatesInNamespace({ + permissions: { ...namespacePermissions, can_create_templates: true }, + }), + ).toBe(true); + }); + + test('should deny template creation when the grant is false, missing, or incomplete', () => { + expect(canCreateTemplatesInNamespace({ permissions: namespacePermissions })).toBe(false); + expect(canCreateTemplatesInNamespace({ permissions: null })).toBe(false); + expect(canCreateTemplatesInNamespace({})).toBe(false); + expect(canCreateTemplatesInNamespace(null)).toBe(false); + expect( + canCreateTemplatesInNamespace({ + permissions: { ...namespacePermissions, can_create_templates: null }, + }), + ).toBe(false); + expect( + canCreateTemplatesInNamespace({ + allowedOperations: ['CREATE_NAMESPACE_TEMPLATES'], + }), + ).toBe(false); + }); + + test('should allow template edit only when every template flag is boolean and can_update is true', () => { + expect(canEditMetadataTemplate({ permissions: { ...templatePermissions, can_update: true } })).toBe(true); + }); + + test('should deny template edit when the grant is false, missing, or incomplete', () => { + expect(canEditMetadataTemplate({ permissions: templatePermissions })).toBe(false); + expect(canEditMetadataTemplate({ permissions: { can_update: true } })).toBe(false); + expect(canEditMetadataTemplate({ permissions: null })).toBe(false); + expect(canEditMetadataTemplate({ canEdit: true })).toBe(false); + expect(canEditMetadataTemplate({ allowedOperations: ['UPDATE_TEMPLATE'] })).toBe(false); + expect(canEditMetadataTemplate(undefined)).toBe(false); + }); +}); diff --git a/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts b/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts new file mode 100644 index 0000000000..05eb5e33d0 --- /dev/null +++ b/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts @@ -0,0 +1,58 @@ +/** + * Create/edit affordances for the metadata template browser. + * + * Retrieval endpoints return these only when the request includes + * `fields=permissions`. A missing or null object stays unknown and does not + * grant access. A present object must carry every documented boolean; anything + * else, including a null flag, is an explicit deny. This matches the + * metadata-ui-client permission adapter. + */ + +const TEMPLATE_PERMISSION_KEYS = [ + 'can_read', + 'can_update', + 'can_delete', + 'can_view_permissions', + 'can_manage_permissions', +] as const; + +const NAMESPACE_PERMISSION_KEYS = [ + ...TEMPLATE_PERMISSION_KEYS, + 'can_create_namespaces', + 'can_create_templates', +] as const; + +function readPermissions(resource: unknown): unknown { + if (!resource || typeof resource !== 'object') { + return undefined; + } + + return (resource as { permissions?: unknown }).permissions; +} + +/** True only when every documented flag is a boolean and the named flag is true. */ +function grantsPermission(permissions: unknown, keys: readonly string[], flag: string): boolean { + if (permissions === undefined || permissions === null) { + return false; + } + if (typeof permissions !== 'object' || Array.isArray(permissions)) { + return false; + } + + const values = permissions as Record; + if (keys.some(key => typeof values[key] !== 'boolean')) { + return false; + } + + return values[flag] === true; +} + +/** True when `permissions.can_create_templates` allows creating a template under this namespace. */ +export function canCreateTemplatesInNamespace(namespace: unknown): boolean { + return grantsPermission(readPermissions(namespace), NAMESPACE_PERMISSION_KEYS, 'can_create_templates'); +} + +/** True when `permissions.can_update` allows editing this template. */ +export function canEditMetadataTemplate(template: unknown): boolean { + return grantsPermission(readPermissions(template), TEMPLATE_PERMISSION_KEYS, 'can_update'); +} From f88dd98ca08f130c6e628b5f24cc42c763cf85aa Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 6 Oct 2026 16:23:43 +0200 Subject: [PATCH 3/5] docs(metadata-sidebar): Drop an internal name from the permissions comment --- .../content-sidebar/utils/metadataTemplatePermissions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts b/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts index 05eb5e33d0..4a8f3b383e 100644 --- a/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts +++ b/src/elements/content-sidebar/utils/metadataTemplatePermissions.ts @@ -4,8 +4,7 @@ * Retrieval endpoints return these only when the request includes * `fields=permissions`. A missing or null object stays unknown and does not * grant access. A present object must carry every documented boolean; anything - * else, including a null flag, is an explicit deny. This matches the - * metadata-ui-client permission adapter. + * else, including a null flag, is an explicit deny. */ const TEMPLATE_PERMISSION_KEYS = [ From 7ad3afe9faf4a7682eec1bb70262d45f8840ac79 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 6 Oct 2026 16:50:58 +0200 Subject: [PATCH 4/5] feat(metadata-sidebar): Adopt metadata-template-browser 3.0.0 Use each template API id and each namespace fqn, and allow 3.x of the browser. --- package.json | 12 +-- .../MetadataTemplateDropdown.tsx | 102 ++++++++++-------- .../useMetadataTemplateItemsService.test.tsx | 6 +- .../hooks/useMetadataTemplateItemsService.ts | 17 +-- yarn.lock | 16 +-- 5 files changed, 74 insertions(+), 79 deletions(-) diff --git a/package.json b/package.json index 3b793a91cd..2afccd6558 100644 --- a/package.json +++ b/package.json @@ -138,10 +138,10 @@ "@box/frontend": "^11.0.1", "@box/item-icon": "^3.2.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^2.7.0", + "@box/metadata-editor": "^2.10.8", "@box/metadata-filter": "^1.80.23", "@box/metadata-taxonomy-picker": "^3.3.31", - "@box/metadata-template-browser": "^2.3.31", + "@box/metadata-template-browser": "^3.0.0", "@box/metadata-template-editor": "^2.5.0", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", @@ -315,10 +315,10 @@ "@box/content-field": "^1.40.23", "@box/copy-input": "^1.42.16", "@box/item-icon": "^3.2.0", - "@box/metadata-editor": "^2.7.0", + "@box/metadata-editor": "^2.10.8", "@box/metadata-filter": "^1.80.23", "@box/metadata-taxonomy-picker": "^3.3.31", - "@box/metadata-template-browser": "^2.3.31", + "@box/metadata-template-browser": "^3.0.0", "@box/metadata-template-editor": "^2.5.0", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", @@ -385,10 +385,10 @@ }, "resolutions": { "@box/blueprint-web": "17.15.4", - "@box/metadata-editor": "^2.7.0", + "@box/metadata-editor": "^2.10.8", "@box/user-selector": "3.0.1", "@box/metadata-taxonomy-picker": "^3.3.31", - "@box/metadata-template-browser": "^2.3.31", + "@box/metadata-template-browser": "^3.0.0", "@box/metadata-template-editor": "^2.5.0", "@box/combobox-with-api": "2.2.25", "@box/box-ai-agent-selector": "2.2.23", diff --git a/src/elements/content-sidebar/MetadataTemplateDropdown.tsx b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx index b0e600529d..456965fffe 100644 --- a/src/elements/content-sidebar/MetadataTemplateDropdown.tsx +++ b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx @@ -8,10 +8,10 @@ * * The metadata-editor package owns only UI; this file owns the wiring. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { AddMetadataTemplateDropdown, AddMetadataTemplateDropdownWithBrowser } from '@box/metadata-editor'; import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; -import type { ItemsService } from '@box/metadata-template-browser'; +import type { ItemsService, MetadataTemplate as BrowserMetadataTemplate } from '@box/metadata-template-browser'; import useMetadataTemplateEventService, { type MetadataTemplateLocator } from './hooks/useMetadataTemplateEventService'; import { getMetadataTemplateNamespaceFqn } from './utils/metadataTemplateIdentity'; @@ -32,11 +32,7 @@ export interface MetadataTemplateDropdownProps { isMetadataTemplateManagementEnabled: boolean; /** Opens the template editor modal in create mode for the given namespace FQN. */ onCreateTemplate?: (namespaceFqn: string) => void; - /** - * Opens the template editor modal in edit mode for the given template. - * The `templateId` is the native API id; the consumer looks it up in - * `templates` to recover `namespaceFqn` and `templateKey`. - */ + /** Opens the template editor modal in edit mode for the given template. */ onEditTemplate?: (args: { namespaceFqn: string; templateKey: string }) => void; /** Whether template creation is allowed at the enterprise root namespace. */ canCreateAtRoot?: boolean; @@ -65,54 +61,48 @@ export default function MetadataTemplateDropdown({ selectedTemplates, templates, }: Readonly) { - // Bridge: native template id → { namespaceFqn, templateKey } for the edit callback. + const templateLocatorsRef = useRef(new Map()); + + const rememberTemplateLocator = useCallback((template: BrowserMetadataTemplate | EditorMetadataTemplate) => { + const namespaceFqn = getMetadataTemplateNamespaceFqn(template); + if (template.id && namespaceFqn && template.templateKey) { + templateLocatorsRef.current.set(template.id, { namespaceFqn, templateKey: template.templateKey }); + } + }, []); + + useEffect(() => { + templates.forEach(rememberTemplateLocator); + }, [rememberTemplateLocator, templates]); + + const rememberTemplates = useCallback( + (entries: BrowserMetadataTemplate[]) => { + entries.forEach(rememberTemplateLocator); + }, + [rememberTemplateLocator], + ); + + // Bridge: template id → { namespaceFqn, templateKey } for the edit callback. const handleEditTemplateById = useCallback( (templateId: string) => { if (!onEditTemplate) return; - // Primary: exact id match against already-loaded editor templates. - const template = templates.find(t => t.id === templateId); - if (template?.templateKey) { - const namespaceFqn = getMetadataTemplateNamespaceFqn(template); - if (namespaceFqn) { - onEditTemplate({ namespaceFqn, templateKey: template.templateKey }); - return; - } + const loaded = templates.find(template => template.id === templateId); + const namespaceFqn = loaded ? getMetadataTemplateNamespaceFqn(loaded) : undefined; + if (loaded?.templateKey && namespaceFqn) { + onEditTemplate({ namespaceFqn, templateKey: loaded.templateKey }); + return; } - // Fallback: ids encoded as "fqn||templateKey" by useMetadataTemplateItemsService. - // This is the only path for child-namespace templates, which the sidebar's - // root-only fetch never loads. - if (templateId.includes('||')) { - const separatorIndex = templateId.indexOf('||'); - const namespaceFqn = templateId.slice(0, separatorIndex); - const templateKey = templateId.slice(separatorIndex + 2); - if (namespaceFqn && templateKey) { - onEditTemplate({ namespaceFqn, templateKey }); - } + const located = templateLocatorsRef.current.get(templateId); + if (located) { + onEditTemplate(located); } }, [templates, onEditTemplate], ); - // Applied rows stay listed but are disabled with a tooltip. Applied instances carry - // their template's id, which is the row id for root templates. Child-namespace rows - // are keyed by the synthesised "fqn||templateKey" id instead, since they are never in - // the sidebar's root-only template list — so both forms go in the set. - const appliedTemplateIds = useMemo(() => { - const ids = new Set(); - - selectedTemplates.forEach(template => { - if (template.id) { - ids.add(template.id); - } - - const namespaceFqn = getMetadataTemplateNamespaceFqn(template); - if (namespaceFqn && template.templateKey) { - ids.add(`${namespaceFqn}||${template.templateKey}`); - } - }); - - return ids; - }, [selectedTemplates]); + const appliedTemplateIds = useMemo( + () => new Set(selectedTemplates.map(template => template.id).filter((id): id is string => Boolean(id))), + [selectedTemplates], + ); const eventService = useMetadataTemplateEventService({ templates, @@ -130,6 +120,24 @@ export default function MetadataTemplateDropdown({ } return { ...itemsService, + ...(itemsService.getTemplates + ? { + getTemplates: async (namespaceFqn, params) => { + const page = await itemsService.getTemplates!(namespaceFqn, params); + rememberTemplates(page.entries); + return page; + }, + } + : {}), + ...(itemsService.getSearchResults + ? { + getSearchResults: async (query, params) => { + const page = await itemsService.getSearchResults!(query, params); + rememberTemplates(page.entries); + return page; + }, + } + : {}), ...(onCreateTemplate ? { createTemplate: async (namespaceFqn: string) => { @@ -147,7 +155,7 @@ export default function MetadataTemplateDropdown({ } : {}), }; - }, [handleEditTemplateById, itemsService, onCreateTemplate, onEditTemplate]); + }, [handleEditTemplateById, itemsService, onCreateTemplate, onEditTemplate, rememberTemplates]); if (isMetadataTemplateManagementEnabled && enterpriseId && browserItemsService) { return ( diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx index 90f3e03bc2..e6a6cecedb 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -206,7 +206,7 @@ describe('useMetadataTemplateItemsService', () => { hidden: false, }, { - id: `${enterpriseFqn}.child||childOnly`, + id: 'api-id-2', type: 'metadata_template', displayName: 'Child Only', scope: `${enterpriseFqn}.child`, @@ -281,7 +281,7 @@ describe('useMetadataTemplateItemsService', () => { await expect(result.current!.getSearchResults(' Le ', { limit: 20, marker: 'cursor-0' })).resolves.toEqual({ entries: [ { - id: `${enterpriseFqn}.legal.contracts||legalHold`, + id: 'api-legal-hold', type: 'metadata_template', displayName: 'Legal Hold', scope: `${enterpriseFqn}.legal.contracts`, @@ -307,7 +307,7 @@ describe('useMetadataTemplateItemsService', () => { ancestors: undefined, }, { - id: 'box.metadata||legalContract', + id: 'api-global', type: 'metadata_template', displayName: 'Legal Contract', scope: 'box.metadata', diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 10b1f932af..40a636398b 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -174,13 +174,7 @@ export default function useMetadataTemplateItemsService( isSameMetadataTemplate(et, { templateKey, scope: templateScope }), ); return { - // Child-namespace templates are never in the editor list, and a raw API - // id carries no namespace or key — so the edit affordance could not - // resolve one. Encode both instead, the format handleEditTemplateById - // already parses. - id: - editorMatch?.id ?? - (templateKey ? `${templateScope}||${templateKey}` : (t.id as string)), + id: editorMatch?.id ?? readNonBlank(t.id) ?? '', type: (t.type as string) ?? 'metadata_template', displayName: ((t.displayName as string) ?? templateKey) || '', scope: templateScope, @@ -239,19 +233,12 @@ export default function useMetadataTemplateItemsService( const templateKey = readNonBlank(hit.templateKey); const namespace = readNonBlank(hit.namespace); const scope = readNonBlank(hit.scope) ?? namespace; - const namespaceFqn = namespace ?? scope; const editorMatch = templates.find(template => isSameMetadataTemplate(template, { templateKey, namespace, scope }), ); return { - // Same id encoding as getTemplates: a raw API id cannot - // recover namespace and key for a child-namespace hit. - id: - editorMatch?.id ?? - (namespaceFqn && templateKey - ? `${namespaceFqn}||${templateKey}` - : readNonBlank(hit.id) || ''), + id: editorMatch?.id ?? readNonBlank(hit.id) ?? '', type: readNonBlank(hit.type) ?? 'metadata_template', displayName: resolveSearchDisplayName(hit, templateKey, editorMatch, customMetadataName), scope, diff --git a/yarn.lock b/yarn.lock index 7acdd7e5bb..320f96d0da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1177,10 +1177,10 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.2.tgz#cd4266b3da62da18560d881e10b429653186be29" integrity sha512-d64TGosx+KRmrLZj4CIyLp42LUiEbgBJ8n8cviMQwTJmfU0g+UwZqLjmQZR1j+Q9D64yV4xHzY9K1t5nInWWeQ== -"@box/metadata-editor@^2.7.0": - version "2.8.1" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.8.1.tgz#bfc4ffe9909cb9a3e3f893ff22dba186f7e1fb21" - integrity sha512-4uuUUxatCSqEsUYojvVM8dOuBh5r2Dkl+FXG3eDOAcbS31nngtNXTl24HdXAEIlim9yYSaWI93+vkleaA4lekA== +"@box/metadata-editor@^2.10.8": + version "2.10.8" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.10.8.tgz#9718f983de2611a01dbe6073dcb6d083e7d3520c" + integrity sha512-yL383TQIu1en0c2VMNHBm0cxQZmLTnieGWDpiVv60KGoIVU1SjUm7Uz62TcwCI50V5yvirzFq9gefdMpFO6vkg== "@box/metadata-filter@^1.80.23": version "1.80.23" @@ -1192,10 +1192,10 @@ resolved "https://registry.yarnpkg.com/@box/metadata-taxonomy-picker/-/metadata-taxonomy-picker-3.3.31.tgz#894bbf641af206d15bb6586ff8d1dbce1e59afcc" integrity sha512-y5gWuDJLOzevfyerAaBuplgv/zWrpjT4JSHCK4z/Ns+KamdltfnuwGXgmy4DfQ3HwqHcaP7FwRQpnRBHavoRgg== -"@box/metadata-template-browser@^2.3.31": - version "2.3.31" - resolved "https://registry.yarnpkg.com/@box/metadata-template-browser/-/metadata-template-browser-2.3.31.tgz#58cd51b6ec8476d38445e754c5ce1daf2937563a" - integrity sha512-Ow7dWqXpuHrzngvmlRaFSJx30PURCx1jkeeFoYNNl1v+bTNH+CrrSNJdDbbVekBvIdjVBNoi4uMZ0pZHLbPZ7w== +"@box/metadata-template-browser@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@box/metadata-template-browser/-/metadata-template-browser-3.0.2.tgz#cca36885aa7870bd2bbe84e1fb46dc411e683656" + integrity sha512-COMvx/sWnNQ49ib5dL3NrDMVTu1aoC1gR+/RUMqmofd+GQ0DkUbLeesMM8Uy7WqAund/plWe8ZkTOneXeEtcJA== "@box/metadata-template-editor@^2.5.0": version "2.5.0" From e6efcc8de415239ceb0c363f6f04640c41c750c2 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 7 Oct 2026 15:57:07 +0200 Subject: [PATCH 5/5] fix(metadata-sidebar): Address comment --- .../useMetadataTemplateItemsService.test.tsx | 32 +++++++++++++++++++ .../hooks/useMetadataTemplateItemsService.ts | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx index e6a6cecedb..c7d3a1bb97 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -323,6 +323,38 @@ describe('useMetadataTemplateItemsService', () => { expect(searchTemplates).toHaveBeenCalledWith(mockFile, { query: 'Le', limit: 20, marker: 'cursor-0' }); }); + test('should not resolve a namespaced search hit to a root editor template sharing its scope', async () => { + searchTemplates.mockResolvedValue({ + entries: [ + { + type: 'metadata_template', + id: 'api-legal-my-template', + templateKey: 'myTemplate', + displayName: 'Legal Template', + namespace: `${enterpriseFqn}.legal`, + scope: enterpriseFqn, + }, + ], + next_marker: undefined, + }); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + await expect(result.current!.getSearchResults('legal', { limit: 20 })).resolves.toEqual({ + entries: [ + expect.objectContaining({ + id: 'api-legal-my-template', + displayName: 'Legal Template', + namespace: `${enterpriseFqn}.legal`, + templateKey: 'myTemplate', + }), + ], + next_marker: undefined, + }); + }); + test('should not call search for a blank query', async () => { const { result } = renderHook(() => useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 40a636398b..f6ca0e253b 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -233,8 +233,10 @@ export default function useMetadataTemplateItemsService( const templateKey = readNonBlank(hit.templateKey); const namespace = readNonBlank(hit.namespace); const scope = readNonBlank(hit.scope) ?? namespace; + // A namespaced hit also reports its root `scope`; matching on it would + // resolve child-namespace hits to a root editor template. const editorMatch = templates.find(template => - isSameMetadataTemplate(template, { templateKey, namespace, scope }), + isSameMetadataTemplate(template, { templateKey, scope: namespace ?? scope }), ); return {