diff --git a/src/api/APIFactory.js b/src/api/APIFactory.js index e2a3897494..8203481f6f 100644 --- a/src/api/APIFactory.js +++ b/src/api/APIFactory.js @@ -507,11 +507,19 @@ class APIFactory { * @return {MetadataAPI} MetadataAPI instance */ getMetadataAPI(shouldDestroy: boolean): MetadataAPI { + // Each call constructs a new client. Carry the mode the sidebar already + // resolved so a later templates request does not fall back to SCOPED + // and ask for `/metadata_templates/global` after Final was selected. + const metadataNamespaceMode = this.metadataAPI && this.metadataAPI.metadataNamespaceMode; + if (shouldDestroy) { this.destroy(); } - this.metadataAPI = new MetadataAPI(this.options); + this.metadataAPI = new MetadataAPI({ + ...this.options, + ...(metadataNamespaceMode ? { metadataNamespaceMode } : {}), + }); return this.metadataAPI; } diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 35908462c4..616b034236 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -72,6 +72,7 @@ import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; import MetadataNamespaces from './MetadataNamespaces'; import { + canonicalMetadataHome, getEnterpriseNamespaceFromInstances as resolveEnterpriseNamespaceFromInstances, getEnterpriseRootFromScopeOrNamespace as resolveEnterpriseRoot, isTemplateExternallyOwned, @@ -215,7 +216,7 @@ class Metadata extends File { getMetadataUrl(id: string, scope?: string, template?: string): string { const baseUrl = `${this.getMetadataInstanceBaseUrl()}/files/${id}/metadata`; if (scope && template) { - return `${baseUrl}/${scope}/${template}`; + return `${baseUrl}/${this.getScopeOrNamespace(scope)}/${template}`; } return baseUrl; } @@ -234,7 +235,7 @@ class Metadata extends File { getMetadataUrlForFolder(id: string, scope?: string, template?: string): string { const baseUrl = `${this.getMetadataInstanceBaseUrl()}/folders/${id}/metadata`; if (scope && template) { - return `${baseUrl}/${scope}/${template}`; + return `${baseUrl}/${this.getScopeOrNamespace(scope)}/${template}`; } return baseUrl; } @@ -292,7 +293,7 @@ class Metadata extends File { * @return {string} API url for getting template schema by template key */ getMetadataTemplateSchemaUrl(templateKey: string, scope?: string = METADATA_SCOPE_ENTERPRISE): string { - return `${this.getMetadataTemplateUrl()}/${scope}/${templateKey}/schema`; + return `${this.getMetadataTemplateUrl()}/${this.getScopeOrNamespace(scope)}/${templateKey}/schema`; } /** @@ -302,7 +303,7 @@ class Metadata extends File { * @return {string} base url for files */ getMetadataTemplateUrlForScope(scope: string): string { - return `${this.getMetadataTemplateUrl()}/${scope}`; + return `${this.getMetadataTemplateUrl()}/${this.getScopeOrNamespace(scope)}`; } /** @see MetadataNamespaces.getMetadataNamespacesUrl */ @@ -736,19 +737,18 @@ class Metadata extends File { const templateKey = instance.$template; const scope = instance.$scope; const namespace = instance.$namespace; - - // Primary match: by scope (SCOPED mode; also works for enterprise-scoped - // instances in MIGRATION mode where $scope is still populated). - // Only run when scope is defined so namespace-only instances (undefined - // $scope) do not incorrectly match templates that also lack scope. - let template = - scope != null ? templates.find(t => t.templateKey === templateKey && t.scope === scope) : undefined; - - // Fallback match: by namespace for namespace-only instances in - // MIGRATION/FINAL mode where $scope is absent. - if (!template && namespace) { - template = templates.find(t => t.templateKey === templateKey && t.namespace === namespace); - } + // `global` and `box.metadata` are one home. A scope-only global instance + // must match a template listed from `box.metadata`, or the sidebar + // fetches `/metadata_templates/global` again and FINAL rejects it. + const instanceHome = canonicalMetadataHome(scope, namespace); + + const template = + instanceHome != null + ? templates.find( + t => + t.templateKey === templateKey && canonicalMetadataHome(t.scope, t.namespace) === instanceHome, + ) + : undefined; if (!template && instanceId) { const instanceEnterpriseRoot = resolveEnterpriseRoot(scope, namespace); @@ -767,7 +767,7 @@ class Metadata extends File { const fetchedTemplates = await this.getTemplates( id, - scope || namespace || instanceEnterpriseRoot || '', + this.getScopeOrNamespace(scope, namespace) || instanceEnterpriseRoot || '', instanceId, isExternallyOwned, ); @@ -881,6 +881,11 @@ class Metadata extends File { }); } + const scope = + template.scope === METADATA_SCOPE_GLOBAL + ? this.getScopeOrNamespace(template.scope, template.namespace) + : template.scope; + return { isExternallyOwned, canEdit: instance.$canEdit && canEdit, @@ -888,7 +893,9 @@ class Metadata extends File { hidden: template.hidden, id: template.id, fields, - scope: template.scope, + // FINAL rewrites a legacy `global` scope so the editor does not + // request `/metadata_templates/global` for this instance. + scope, namespace: template.namespace, templateKey: template.templateKey, type: instance.$type, @@ -1050,6 +1057,12 @@ class Metadata extends File { const cache: APICache = this.getCache(); const key = this.getMetadataCacheKey(id); + // Per-call mode for this fetch; persist on the instance so later + // URL helpers on this client match. APIFactory copies it onto the + // next Metadata client, which would otherwise default to SCOPED. + const metadataNamespaceMode = options.metadataNamespaceMode || this.metadataNamespaceMode; + this.metadataNamespaceMode = metadataNamespaceMode; + // Clear the cache if needed if (options.forceFetch) { cache.unset(key); @@ -1066,12 +1079,6 @@ class Metadata extends File { try { const customPropertiesTemplate: MetadataTemplate = this.getCustomPropertiesTemplate(); - // Per-call mode for this fetch; persist on the instance so later - // URL helpers (create/update/delete) on the same Metadata client - // match. Not stored on Base — File/Users/etc. do not use it. - const metadataNamespaceMode = options.metadataNamespaceMode || this.metadataNamespaceMode; - this.metadataNamespaceMode = metadataNamespaceMode; - const { instances, globalTemplates, enterpriseTemplates } = metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED ? await this.fetchTemplatesAndInstancesNamespaced( diff --git a/src/api/__tests__/APIFactory.test.js b/src/api/__tests__/APIFactory.test.js index f8e8918d27..9b8f098d22 100644 --- a/src/api/__tests__/APIFactory.test.js +++ b/src/api/__tests__/APIFactory.test.js @@ -24,7 +24,7 @@ import BoxEditAPI from '../box-edit'; import MetadataQueryAPI from '../MetadataQuery'; import FileCollaborationsAPI from '../FileCollaborations'; import FolderCollaborationsAPI from '../FolderCollaborations'; -import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD } from '../../constants'; +import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD, METADATA_SCOPE_MODE_FINAL } from '../../constants'; let factory; @@ -351,6 +351,16 @@ describe('api/APIFactory', () => { expect(metadataAPI.options.apiHost).toBe(DEFAULT_HOSTNAME_API); expect(metadataAPI.options.uploadHost).toBe(DEFAULT_HOSTNAME_UPLOAD); }); + + test('should carry the resolved namespace mode onto the next metadata client', () => { + const first = factory.getMetadataAPI(false); + first.metadataNamespaceMode = METADATA_SCOPE_MODE_FINAL; + + const second = factory.getMetadataAPI(false); + + expect(second).not.toBe(first); + expect(second.metadataNamespaceMode).toBe(METADATA_SCOPE_MODE_FINAL); + }); }); describe('getFileCollaboratorsAPI()', () => { diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 07432fb8dd..a72ff744b1 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -156,6 +156,13 @@ describe('api/Metadata', () => { 'https://api.box.com/2.0/metadata_templates/scope', ); }); + + test('should rewrite global to box.metadata in FINAL mode', () => { + metadata.metadataNamespaceMode = 'FINAL'; + expect(metadata.getMetadataTemplateUrlForScope('global')).toBe( + `https://api.box.com/2.0/metadata_templates/${METADATA_NAMESPACE_GLOBAL}`, + ); + }); }); describe('getCustomPropertiesTemplate()', () => { @@ -538,6 +545,24 @@ describe('api/Metadata', () => { expect(result.fields[0].value).toBe('DetailedStringValue'); }); + + test('should rewrite a legacy global scope to box.metadata in FINAL mode', () => { + metadata.metadataNamespaceMode = 'FINAL'; + + const result = metadata.createTemplateInstance( + { $id: '321', $template: 'properties', $canEdit: true }, + { + displayName: 'Custom Metadata', + fields: [], + id: '123456', + templateKey: 'properties', + scope: 'global', + }, + true, + ); + + expect(result.scope).toBe(METADATA_NAMESPACE_GLOBAL); + }); }); describe('getTaxonomyLevelsForTemplates()', () => { @@ -1204,6 +1229,26 @@ describe('api/Metadata', () => { expect(result.isExternallyOwned).toBe(true); expect(metadata.getTemplates).toBeCalledWith('id', 'enterprise_999.legal', 'instanceId', true); }); + + test('should match a scope-only global instance to a box.metadata template without another fetch', async () => { + metadata.getTemplates = jest.fn(); + const boxMetadataTemplate = { + id: 11, + templateKey: 'properties', + namespace: METADATA_NAMESPACE_GLOBAL, + }; + const result = await metadata.getTemplateForInstance( + 'id', + { + $id: 'instanceId', + $scope: 'global', + $template: 'properties', + }, + [boxMetadataTemplate], + ); + expect(result.template).toBe(boxMetadataTemplate); + expect(metadata.getTemplates).not.toHaveBeenCalled(); + }); }); describe('getEditors()', () => { @@ -1557,6 +1602,39 @@ describe('api/Metadata', () => { ); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterpriseRoot', true, false); }); + test('should request box.metadata instead of global in FINAL mode', async () => { + const file = { + id: 'id', + is_externally_owned: false, + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + + metadata.errorHandler = jest.fn(); + metadata.successHandler = jest.fn(); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(false); + metadata.getCache = jest.fn().mockReturnValueOnce(cache); + metadata.getMetadataCacheKey = jest.fn().mockReturnValueOnce('cache_id_metadata'); + metadata.getInstances = jest.fn().mockResolvedValueOnce([]); + metadata.getEditors = jest.fn().mockResolvedValueOnce([]); + metadata.getTemplateInstances = jest.fn().mockResolvedValueOnce([]); + metadata.getCustomPropertiesTemplate = jest.fn().mockReturnValueOnce('custom'); + metadata.getUserAddableTemplates = jest.fn().mockReturnValueOnce('templates'); + metadata.getTemplates = jest.fn().mockResolvedValueOnce('global').mockResolvedValueOnce('enterpriseRoot'); + metadata.extractClassification = jest.fn().mockReturnValueOnce([]); + + await metadata.getMetadata(file, jest.fn(), jest.fn(), true, { + enterpriseFqn: 'enterprise_123456', + metadataNamespaceMode: 'FINAL', + }); + + expect(metadata.metadataNamespaceMode).toBe('FINAL'); + expect(metadata.getTemplates).toHaveBeenCalledWith(file.id, METADATA_NAMESPACE_GLOBAL); + expect(metadata.getTemplates).not.toHaveBeenCalledWith(file.id, 'global'); + expect(metadata.getTemplates).toHaveBeenCalledWith(file.id, 'enterprise_123456'); + }); test('should make request and update cache and call success handler for Metadata Redesign', async () => { const file = { id: 'id', diff --git a/src/api/__tests__/metadataNamespaceUtils.test.js b/src/api/__tests__/metadataNamespaceUtils.test.js index 54e40dff6d..3948b3721d 100644 --- a/src/api/__tests__/metadataNamespaceUtils.test.js +++ b/src/api/__tests__/metadataNamespaceUtils.test.js @@ -1,4 +1,5 @@ import { + canonicalMetadataHome, getEnterpriseNamespaceFromInstances, getEnterpriseRootFromScopeOrNamespace, isTemplateExternallyOwned, @@ -39,6 +40,18 @@ describe('api/metadataNamespaceUtils', () => { }); }); + describe('canonicalMetadataHome()', () => { + test('should treat scope-only global and box.metadata as the same home', () => { + expect(canonicalMetadataHome('global', undefined)).toBe(METADATA_NAMESPACE_GLOBAL); + expect(canonicalMetadataHome(undefined, 'box.metadata')).toBe(METADATA_NAMESPACE_GLOBAL); + expect(canonicalMetadataHome('global', 'box.metadata')).toBe(METADATA_NAMESPACE_GLOBAL); + }); + + test('should keep an enterprise namespace ahead of a legacy scope', () => { + expect(canonicalMetadataHome('enterprise_123', 'enterprise_123.legal')).toBe('enterprise_123.legal'); + }); + }); + describe('resolveScopeOrNamespace()', () => { test('should map global to box.metadata in FINAL mode', () => { expect(resolveScopeOrNamespace(METADATA_SCOPE_MODE_FINAL, 'global')).toBe(METADATA_NAMESPACE_GLOBAL); diff --git a/src/api/metadataNamespaceUtils.js b/src/api/metadataNamespaceUtils.js index 1488bee0e1..60797cd180 100644 --- a/src/api/metadataNamespaceUtils.js +++ b/src/api/metadataNamespaceUtils.js @@ -47,6 +47,32 @@ export function getEnterpriseNamespaceFromInstances( return null; } +/** + * `global` and `box.metadata` name the same Box-owned template home. + * Comparison only — request paths still go through `resolveScopeOrNamespace`. + */ +function isBoxOwnedMetadataHome(value: ?string): boolean { + return value === METADATA_SCOPE_GLOBAL || value === METADATA_NAMESPACE_GLOBAL; +} + +/** + * Stable identity for matching a template to an instance. + * + * Namespace wins when it names a real FQN. A scope-only `global` and a + * namespace-only `box.metadata` compare as the same home, so a file that still + * carries a legacy global instance matches templates listed from `box.metadata` + * without a second fetch of `/metadata_templates/global`. + */ +export function canonicalMetadataHome(scope: ?string, namespace: ?string): ?string { + if (namespace && !isBoxOwnedMetadataHome(namespace)) { + return namespace; + } + if (isBoxOwnedMetadataHome(namespace) || isBoxOwnedMetadataHome(scope)) { + return METADATA_NAMESPACE_GLOBAL; + } + return namespace || scope || null; +} + /** * Resolves the URL path segment for a scope/namespace given the current mode. * diff --git a/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts index 769823e5a6..449653a35b 100644 --- a/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts +++ b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts @@ -115,6 +115,15 @@ describe('metadataTemplateIdentity', () => { ).toBe(false); }); + test('matches a scope-only global template to a box.metadata template', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', scope: 'global' }, + { templateKey: 'contract', namespace: 'box.metadata' }, + ), + ).toBe(true); + }); + test('matches when one side stores the FQN in scope and the other in namespace', () => { expect( isSameMetadataTemplate( diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx index f62400a0ba..fff528f517 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -1,6 +1,6 @@ /// import { renderHook } from '@testing-library/react'; -import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import { METADATA_TEMPLATE_CLASSIFICATION, METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; import useMetadataTemplateItemsService from '../hooks/useMetadataTemplateItemsService'; describe('useMetadataTemplateItemsService', () => { @@ -155,6 +155,65 @@ describe('useMetadataTemplateItemsService', () => { expect(entries.map(entry => entry.templateKey)).toEqual(['visibleTemplate']); }); + test('should drop the classification template from getTemplates', async () => { + listTemplatesForNamespace.mockResolvedValue({ + entries: [ + { + id: 'api-id-1', + templateKey: 'visibleTemplate', + namespace: `${enterpriseFqn}.legal`, + displayName: 'Visible', + }, + { + id: 'api-id-2', + templateKey: METADATA_TEMPLATE_CLASSIFICATION, + namespace: enterpriseFqn, + displayName: 'Classification', + }, + ], + next_marker: undefined, + }); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + const { entries } = await result.current!.getTemplates(`${enterpriseFqn}.legal`, { + limit: 50, + marker: undefined, + }); + + expect(entries.map(entry => entry.templateKey)).toEqual(['visibleTemplate']); + }); + + test('should drop the classification template from search results', async () => { + searchTemplates.mockResolvedValue({ + entries: [ + { + id: 'search-visible', + templateKey: 'visibleTemplate', + scope: enterpriseFqn, + displayName: 'Visible', + }, + { + id: 'search-classification', + templateKey: METADATA_TEMPLATE_CLASSIFICATION, + scope: enterpriseFqn, + displayName: 'Classification', + }, + ], + next_marker: undefined, + }); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + const { entries } = await result.current!.getSearchResults('class', { limit: 20 }); + + expect(entries.map(entry => entry.templateKey)).toEqual(['visibleTemplate']); + }); + test('should map getTemplates entries and prefer editor template ids', async () => { listTemplatesForNamespace.mockResolvedValue({ entries: [ @@ -196,6 +255,16 @@ describe('useMetadataTemplateItemsService', () => { await expect(result.current!.getTemplates(enterpriseFqn, { limit: 50, marker: undefined })).resolves.toEqual({ entries: [ + { + id: 'editor-props', + type: 'metadata_template', + displayName: 'Custom Metadata', + scope: 'global', + namespace: 'box.metadata', + templateKey: METADATA_TEMPLATE_PROPERTIES, + canEdit: false, + hidden: false, + }, { id: 'editor-1', type: 'metadata_template', @@ -475,6 +544,88 @@ describe('useMetadataTemplateItemsService', () => { await expect(result.current!.getSearchResults('Le', { limit: 20 })).rejects.toBe(error); }); + test('should offer only custom metadata from the box-owned home on the first enterprise-root page', async () => { + const templatesWithAnotherGlobalTemplate = [ + ...templates, + { + id: 'editor-sign', + templateKey: 'boxSign', + scope: 'global', + namespace: 'box.metadata', + type: 'metadata_template', + displayName: 'Sign', + hidden: false, + fields: [], + }, + ]; + + const { result } = renderHook(() => + useMetadataTemplateItemsService( + api as never, + mockFile as never, + enterpriseFqn, + templatesWithAnotherGlobalTemplate as never, + ), + ); + + const { entries } = await result.current!.getTemplates(enterpriseFqn, { limit: 50 }); + + expect(entries.map(entry => entry.templateKey)).toEqual([METADATA_TEMPLATE_PROPERTIES, 'myTemplate']); + }); + + test('should omit custom metadata from a marked enterprise-root page and a child namespace', async () => { + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + const markedRootPage = await result.current!.getTemplates(enterpriseFqn, { limit: 50, marker: 'cursor-1' }); + const childPage = await result.current!.getTemplates(`${enterpriseFqn}.legal`, { limit: 50 }); + + expect(markedRootPage.entries.map(entry => entry.templateKey)).toEqual(['myTemplate']); + expect(childPage.entries).toEqual([]); + }); + + test('should offer custom metadata on the first search page when the query matches', async () => { + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + const { entries } = await result.current!.getSearchResults('custom', { limit: 20 }); + + expect(entries).toEqual([ + expect.objectContaining({ + id: 'editor-props', + displayName: 'Custom Metadata', + scope: 'global', + namespace: 'box.metadata', + templateKey: METADATA_TEMPLATE_PROPERTIES, + }), + ]); + }); + + test('should not offer custom metadata when search does not match or the page is not the first', async () => { + searchTemplates.mockResolvedValue({ + entries: [ + { + id: 'api-legal-hold', + templateKey: 'legalHold', + namespace: `${enterpriseFqn}.legal`, + displayName: 'Legal Hold', + }, + ], + }); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + const unmatched = await result.current!.getSearchResults('legal', { limit: 20 }); + const laterPage = await result.current!.getSearchResults('custom', { limit: 20, marker: 'cursor-1' }); + + expect(unmatched.entries.map(entry => entry.templateKey)).toEqual(['legalHold']); + expect(laterPage.entries.map(entry => entry.templateKey)).toEqual(['legalHold']); + }); + 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), @@ -482,6 +633,12 @@ describe('useMetadataTemplateItemsService', () => { await expect(result.current!.getTemplates(enterpriseFqn, { limit: 50, marker: undefined })).resolves.toEqual({ entries: [ + expect.objectContaining({ + id: 'editor-props', + templateKey: METADATA_TEMPLATE_PROPERTIES, + scope: 'global', + namespace: 'box.metadata', + }), expect.objectContaining({ id: 'editor-1', templateKey: 'myTemplate', diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index fe99e31135..21fcb2f19a 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -10,7 +10,12 @@ import { import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; import API from '../../../api'; -import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import { + METADATA_NAMESPACE_GLOBAL, + METADATA_SCOPE_GLOBAL, + METADATA_TEMPLATE_CLASSIFICATION, + 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'; @@ -34,6 +39,73 @@ function isHiddenTemplate(template: { hidden?: unknown; isHidden?: unknown }): b return template.hidden === true || template.isHidden === true; } +/** + * Classification is edited from the classification control. The template + * picker omits it, matching `getUserAddableTemplates`. + */ +function isClassificationTemplate(template: { templateKey?: unknown }): boolean { + return template.templateKey === METADATA_TEMPLATE_CLASSIFICATION; +} + +function isOmittedPickerTemplate(template: { hidden?: unknown; isHidden?: unknown; templateKey?: unknown }): boolean { + return isHiddenTemplate(template) || isClassificationTemplate(template); +} + +/** `global` and `box.metadata` name the same Box-owned template home. */ +function isBoxOwnedMetadataHome(fqn: string | undefined): boolean { + return fqn === METADATA_SCOPE_GLOBAL || fqn === METADATA_NAMESPACE_GLOBAL; +} + +function loadedTemplateHome(template: EditorMetadataTemplate): string | undefined { + return getMetadataTemplateNamespaceFqn(template as EditorMetadataTemplate & { namespace?: string }); +} + +/** + * Custom Metadata (`properties`) is a client-built row under `global` / `box.metadata`. + * It has no stored schema, so template search never returns it. + */ +function isLocalCustomMetadataTemplate(template: EditorMetadataTemplate): boolean { + return ( + template.templateKey === METADATA_TEMPLATE_PROPERTIES && isBoxOwnedMetadataHome(loadedTemplateHome(template)) + ); +} + +function matchesMetadataSearchQuery( + template: EditorMetadataTemplate, + query: string, + customMetadataName: string, +): boolean { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) { + return false; + } + const visibleName = resolveDisplayName(template, customMetadataName); + return [visibleName, template.displayName, template.templateKey].some( + value => typeof value === 'string' && value.toLocaleLowerCase().includes(normalizedQuery), + ); +} + +function toLoadedBrowserTemplate( + template: EditorMetadataTemplate, + customMetadataName: string, +): BrowserMetadataTemplate { + const home = loadedTemplateHome(template); + return { + id: template.id, + type: template.type ?? 'metadata_template', + displayName: resolveDisplayName(template, customMetadataName), + scope: template.scope, + namespace: isBoxOwnedMetadataHome(home) ? METADATA_NAMESPACE_GLOBAL : home, + templateKey: template.templateKey, + canEdit: canEditMetadataTemplate(template), + hidden: template.hidden ?? false, + }; +} + +function isAlreadyListed(row: BrowserMetadataTemplate, entries: BrowserMetadataTemplate[]): boolean { + return entries.some(entry => (row.id && entry.id === row.id) || isSameMetadataTemplate(row, entry)); +} + function readNonBlank(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } @@ -183,7 +255,7 @@ export default function useMetadataTemplateItemsService( // Map the raw API response to the browser-expected shape. // The API returns camelCase fields; we normalise displayName and scope/namespace. const listed: BrowserMetadataTemplate[] = (result.entries ?? []) - .filter((t: Record) => !isHiddenTemplate(t)) + .filter((t: Record) => !isOmittedPickerTemplate(t)) .flatMap((t: Record) => { const templateKey = readNonBlank(t.templateKey); const templateScope = @@ -220,7 +292,7 @@ export default function useMetadataTemplateItemsService( !!t.templateKey && fqn === namespaceFQN && !listedKeys.has(t.templateKey) && - !isHiddenTemplate(t) + !isOmittedPickerTemplate(t) ); }) .map(t => ({ @@ -233,7 +305,18 @@ export default function useMetadataTemplateItemsService( hidden: t.hidden ?? false, })); - return { entries: [...fromLoaded, ...listed], next_marker: result.next_marker }; + const page = [...fromLoaded, ...listed]; + // The namespace list does not include Custom Metadata. It is a client-built + // `properties` row, so the first enterprise-root page adds that row alone. + const customMetadataEntries = + namespaceFQN === enterpriseFqn && !readNonBlank(params.marker) + ? templates + .filter(template => isLocalCustomMetadataTemplate(template)) + .map(template => toLoadedBrowserTemplate(template, customMetadataName)) + .filter(row => row.id && !isAlreadyListed(row, page)) + : []; + + return { entries: [...customMetadataEntries, ...page], next_marker: result.next_marker }; }, getSearchResults: async ( @@ -254,7 +337,7 @@ export default function useMetadataTemplateItemsService( }); const entries: BrowserMetadataTemplate[] = (result.entries ?? []) - .filter((hit: Record) => !isHiddenTemplate(hit)) + .filter((hit: Record) => !isOmittedPickerTemplate(hit)) .flatMap((hit: Record) => { const templateKey = readNonBlank(hit.templateKey); const namespace = readNonBlank(hit.namespace); @@ -289,7 +372,23 @@ export default function useMetadataTemplateItemsService( ]; }); - return { entries, next_marker: readNonBlank(result.next_marker) }; + // Custom Metadata has no stored schema, so search never returns it. + // Offer the loaded row on the first page when the query matches. + const customMetadataEntries = readNonBlank(params.marker) + ? [] + : templates + .filter( + template => + isLocalCustomMetadataTemplate(template) && + matchesMetadataSearchQuery(template, normalizedQuery, customMetadataName), + ) + .map(template => toLoadedBrowserTemplate(template, customMetadataName)) + .filter(row => row.id && !isAlreadyListed(row, entries)); + + return { + entries: [...customMetadataEntries, ...entries], + next_marker: readNonBlank(result.next_marker), + }; }, }; }, [api, file, enterpriseFqn, templates, customMetadataName]); diff --git a/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts index c765d8614a..0db143be05 100644 --- a/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts +++ b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts @@ -63,9 +63,20 @@ export function resolveCreateMetadataTemplateKey(body: { displayName: string; te return body.templateKey?.trim() || deriveMetadataTemplateKey(body.displayName); } +const METADATA_SCOPE_GLOBAL = 'global'; +const METADATA_NAMESPACE_GLOBAL = 'box.metadata'; + +/** + * `global` and `box.metadata` name the same template scope. + * Other FQNs are unchanged. + */ +function canonicalMetadataHome(fqn: string): string { + return fqn === METADATA_SCOPE_GLOBAL ? METADATA_NAMESPACE_GLOBAL : fqn; +} + /** Every FQN a template/instance identifies itself by, most specific first. */ function getIdentifiers(template: MetadataTemplateIdentity): string[] { - return [template.namespace, template.scope].filter((fqn): fqn is string => fqn != null); + return [template.namespace, template.scope].filter((fqn): fqn is string => fqn != null).map(canonicalMetadataHome); } /** @@ -86,7 +97,7 @@ export function isSameMetadataTemplate(a: MetadataTemplateIdentity, b: MetadataT return false; } if (a.namespace != null && b.namespace != null) { - return a.namespace === b.namespace; + return canonicalMetadataHome(a.namespace) === canonicalMetadataHome(b.namespace); } const aIdentifiers = getIdentifiers(a); const bIdentifiers = getIdentifiers(b);