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);