From 1c9824d72cfbb271db68b5764fca6b313767e3b9 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 15 Jul 2026 11:40:26 +0200 Subject: [PATCH 01/14] fix: support mid-migration metadata templates --- package.json | 24 +- src/api/Base.js | 11 + src/api/Metadata.js | 433 ++++++++++++++++-- src/api/metadataNamespaceMocks.js | 220 +++++++++ src/common/types/api.js | 1 + src/common/types/metadata.js | 9 +- src/constants.js | 10 + .../MetadataInstanceEditor.tsx | 2 +- .../MetadataSidebarRedesign.tsx | 276 ++++++++--- .../MetadataTemplateDropdown.tsx | 114 +++++ .../MetadataSidebarRedesign.test.tsx | 13 + .../hooks/useMetadataNamespaceMode.ts | 73 +++ .../hooks/useMetadataTemplateEditor.tsx | 140 ++++++ .../hooks/useMetadataTemplateEventService.ts | 68 +++ .../hooks/useMetadataTemplateItemsService.ts | 130 ++++++ .../hooks/useSidebarMetadataFetcher.ts | 13 +- yarn.lock | 58 ++- 17 files changed, 1479 insertions(+), 116 deletions(-) create mode 100644 src/api/metadataNamespaceMocks.js create mode 100644 src/elements/content-sidebar/MetadataTemplateDropdown.tsx create mode 100644 src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts create mode 100644 src/elements/content-sidebar/hooks/useMetadataTemplateEditor.tsx create mode 100644 src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts create mode 100644 src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts diff --git a/package.json b/package.json index 8307a36521..a75c6c6005 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,11 @@ "last 2 Edge versions", "last 2 iOS versions" ], - "development": ["last 1 Chrome versions", "last 1 Firefox versions", "last 1 Safari versions"] + "development": [ + "last 1 Chrome versions", + "last 1 Firefox versions", + "last 1 Safari versions" + ] }, "husky": { "hooks": { @@ -154,6 +158,9 @@ "@chromatic-com/storybook": "^5.2.1", "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.6.0", "@formatjs/intl-pluralrules": "^1.5.2", @@ -293,7 +300,8 @@ "webpack": "^5.105.4", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.4" + "webpack-dev-server": "^5.2.4", + "yup": "^1.6.1" }, "peerDependencies": { "@box/activity-feed": "^2.3.12", @@ -320,6 +328,9 @@ "@box/unified-share-modal": "^2.15.16", "@box/uploads-manager": "^2.4.0", "@box/user-selector": "^2.2.8", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hapi/address": "^2.1.4", "@tanstack/react-virtual": "^3.13.12", "axios": "^0.33.0", @@ -363,7 +374,8 @@ "sass": "1.77.6", "scroll-into-view-if-needed": "^2.2.31", "tabbable": "^1.1.3", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "yup": "^1.6.1" }, "resolutions": { "@sigstore/core": "3.2.1", @@ -375,6 +387,8 @@ "tar": "^7.5.19" }, "msw": { - "workerDirectory": [".storybook/public"] + "workerDirectory": [ + ".storybook/public" + ] } -} +} \ No newline at end of file diff --git a/src/api/Base.js b/src/api/Base.js index 976a4ff431..5081a1dd91 100644 --- a/src/api/Base.js +++ b/src/api/Base.js @@ -17,6 +17,7 @@ import { HTTP_POST, HTTP_PUT, HTTP_DELETE, + METADATA_SCOPE_MODE_SCOPED, } from '../constants'; import type { ElementsErrorCallback, APIOptions } from '../common/types/api'; import type APICache from '../utils/Cache'; @@ -59,6 +60,15 @@ class Base { */ metadataApiHost: ?string; + /** + * Namespace migration mode for the Box Metadata Namespaces API. + * Controls whether requests use legacy scopes ('SCOPED'), both ('MIGRATION'), + * or namespace-only ('FINAL'). Defaults to 'SCOPED'. + * + * @property {string} + */ + metadataNamespaceMode: string; + /** * @property {string} */ @@ -118,6 +128,7 @@ class Base { this.cache = options.cache || new Cache(); this.apiHost = options.apiHost || DEFAULT_HOSTNAME_API; this.metadataApiHost = options.metadataApiHost; + this.metadataNamespaceMode = options.metadataNamespaceMode || METADATA_SCOPE_MODE_SCOPED; this.uploadHost = options.uploadHost || DEFAULT_HOSTNAME_UPLOAD; // @TODO: avoid keeping another copy of data in this.options this.options = { diff --git a/src/api/Metadata.js b/src/api/Metadata.js index a8067aafa2..775032428b 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -30,8 +30,15 @@ import { AI_ACCEPTED_PROCESS, AI_EXTRACTED_PROCESS, HEADER_CONTENT_TYPE, + HEADER_BOX_VERSION, + METADATA_NAMESPACE_MIGRATION_FIELD, + METADATA_NAMESPACE_FINAL_FIELD, + METADATA_SCOPE_MODE_SCOPED, + METADATA_SCOPE_MODE_MIGRATION, + METADATA_SCOPE_MODE_FINAL, METADATA_SCOPE_ENTERPRISE, METADATA_SCOPE_GLOBAL, + METADATA_NAMESPACE_GLOBAL, METADATA_TEMPLATE_FETCH_LIMIT, METADATA_TEMPLATE_PROPERTIES, METADATA_TEMPLATE_CLASSIFICATION, @@ -41,7 +48,9 @@ import { CACHE_PREFIX_METADATA, ERROR_CODE_UPDATE_SKILLS, ERROR_CODE_UPDATE_METADATA, + ERROR_CODE_UPDATE_METADATA_TEMPLATE, ERROR_CODE_CREATE_METADATA, + ERROR_CODE_CREATE_METADATA_TEMPLATE, ERROR_CODE_DELETE_METADATA, ERROR_CODE_FETCH_METADATA, ERROR_CODE_FETCH_METADATA_TEMPLATES, @@ -67,6 +76,15 @@ import type { } from '../common/types/metadata'; import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; +// TODO(MDX-2136): remove this import when namespace API is deployed +import { + IS_NAMESPACE_API_MOCKED, + mockListNamespaces, + mockListTemplatesForNamespace, + mockCreateMetadataTemplate, + mockUpdateMetadataTemplate, + mockGetTemplateSchemaForEditor, +} from './metadataNamespaceMocks'; class Metadata extends File { /** @@ -109,6 +127,29 @@ class Metadata extends File { return `${CACHE_PREFIX_METADATA}template_schema_${templateKey}`; } + /** + * Resolves the URL path segment for a given scope/namespace, accounting for + * the current namespace migration mode. + * + * - In FINAL mode, the legacy 'global' scope is replaced by 'box.metadata'. + * - For namespace-only resources (scope absent), the namespace FQN is used + * directly as the path segment. + * - All other scopes are passed through unchanged. + * + * @param {?string} scope - legacy scope value (e.g. 'global', 'enterprise_123456') + * @param {?string} [namespace] - namespace FQN used as fallback when scope is absent + * @return {string} resolved URL path segment + */ + getScopeOrNamespace(scope: ?string, namespace?: ?string): string { + if (this.metadataNamespaceMode === METADATA_SCOPE_MODE_FINAL && scope === METADATA_SCOPE_GLOBAL) { + return METADATA_NAMESPACE_GLOBAL; + } + if (!scope && namespace) { + return namespace; + } + return scope || ''; + } + /** * Base URL used for metadata *instance* endpoints (file/folder * `/metadata/...`). @@ -213,13 +254,20 @@ class Metadata extends File { } /** - * API URL for getting metadata template schema by template key + * API URL for getting metadata template schema by template key. + * + * In SCOPED mode the path segment is the `enterprise` shorthand. + * In MIGRATION/FINAL mode the API requires the full scope value (e.g. + * `enterprise_123456`) or a namespace FQN, so callers should pass the + * resolved scope/namespace when operating in those modes. * * @param {string} templateKey - metadata template key + * @param {string} [scope] - scope or namespace FQN; defaults to the + * `enterprise` shorthand for backward compatibility with SCOPED mode * @return {string} API url for getting template schema by template key */ - getMetadataTemplateSchemaUrl(templateKey: string): string { - return `${this.getMetadataTemplateUrl()}/enterprise/${templateKey}/schema`; + getMetadataTemplateSchemaUrl(templateKey: string, scope?: string = METADATA_SCOPE_ENTERPRISE): string { + return `${this.getMetadataTemplateUrl()}/${scope}/${templateKey}/schema`; } /** @@ -232,6 +280,235 @@ class Metadata extends File { return `${this.getMetadataTemplateUrl()}/${scope}`; } + /** + * API URL for listing child namespaces under a given namespace FQN. + * + * @param {string} namespaceFqn - namespace FQN (e.g. "enterprise_123456") + * @return {string} URL for namespace listing + */ + getMetadataNamespacesUrl(namespaceFqn: string): string { + return `${this.getBaseApiUrl()}/metadata_namespaces/${namespaceFqn}`; + } + + /** + * Lists child namespaces under a given namespace FQN. + * Used by the namespace browser's ItemsService.getNamespaces. + * + * @param {BoxItem} file - current file (typed id is used for token auth) + * @param {string} namespaceFqn - parent namespace FQN + * @param {Object} params - pagination params (limit, marker) + * @return {Promise} namespace listing response + */ + async listNamespaces( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + // TODO(MDX-2136): remove next line when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) return mockListNamespaces(file, namespaceFqn, params); + + const { id }: BoxItem = file; + const url = this.getMetadataNamespacesUrl(namespaceFqn); + try { + const response = await this.xhr.get({ + url, + id: getTypedFileId(id), + params: { limit: params.limit, marker: params.marker }, + }); + return getProp(response, 'data', { entries: [] }); + } catch (e) { + return { entries: [] }; + } + } + + /** + * Lists templates under a namespace FQN with cursor pagination. + * Used by the namespace browser's ItemsService.getTemplates. + * + * @param {BoxItem} file - current file (typed id is used for token auth) + * @param {string} namespaceFqn - namespace FQN + * @param {Object} params - pagination params (limit, marker) + * @return {Promise} template listing response + */ + async listTemplatesForNamespace( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + // TODO(MDX-2136): remove next line when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) return mockListTemplatesForNamespace(file, namespaceFqn, params); + + const { id }: BoxItem = file; + const url = this.getMetadataTemplateUrlForScope(namespaceFqn); + try { + const response = await this.xhr.get({ + url, + id: getTypedFileId(id), + params: { limit: params.limit, marker: params.marker }, + }); + return getProp(response, 'data', { entries: [] }); + } catch (e) { + return { entries: [] }; + } + } + + /** + * Returns the URL for the enterprise configurations endpoint. + * + * @param {string} enterpriseNumericId - numeric enterprise ID (without "enterprise_" prefix) + * @return {string} URL + */ + getEnterpriseConfigurationsUrl(enterpriseNumericId: string): string { + return `${this.getBaseApiUrl()}/enterprise_configurations/${enterpriseNumericId}`; + } + + /** + * Fetches the metadata namespace migration mode for the given enterprise from the + * enterprise_configurations endpoint. + * + * The mode is derived from two boolean feature flags in `content_and_sharing`: + * - neither enabled → SCOPED (legacy, pre-migration) + * - migration only → MIGRATION + * - both enabled → FINAL + * + * Returns `null` when the request fails so callers can fall back safely. + * + * @param {string} enterpriseNumericId - numeric enterprise ID (without "enterprise_" prefix) + * @return {Promise} one of the METADATA_SCOPE_MODE_* constants, or null on error + */ + async getMetadataNamespaceMode(file: BoxItem, enterpriseNumericId: string): Promise { + const url = this.getEnterpriseConfigurationsUrl(enterpriseNumericId); + + const { id }: BoxItem = file; + try { + const response = await this.xhr.get({ + id: getTypedFileId(id), + url, + params: { categories: 'content_and_sharing' }, + headers: { [HEADER_BOX_VERSION]: '2025.0' }, + }); + const contentAndSharing = getProp(response, 'data.content_and_sharing', {}); + const isMigration = getProp(contentAndSharing, `${METADATA_NAMESPACE_MIGRATION_FIELD}.value`, false); + const isFinal = getProp(contentAndSharing, `${METADATA_NAMESPACE_FINAL_FIELD}.value`, false); + + if (isFinal) return METADATA_SCOPE_MODE_FINAL; + if (isMigration) return METADATA_SCOPE_MODE_MIGRATION; + return METADATA_SCOPE_MODE_SCOPED; + } catch (e) { + return null; + } + } + + /** + * Creates a new namespaced metadata template. + * Maps to POST /metadata_templates/schema with `namespace` in body (MIGRATION/FINAL mode). + * + * @param {Object} body - template create body (namespace, templateKey, displayName, hidden, fields) + * @param {Function} successCallback - called with the created template on success + * @param {Function} errorCallback - called with error on failure + * @return {Promise} + */ + async createMetadataTemplate( + file: BoxItem, + body: Object, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + // TODO(MDX-2136): remove next two lines when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) { + mockCreateMetadataTemplate(file, body, successCallback); + return; + } + + const { id }: BoxItem = file; + this.errorCode = ERROR_CODE_CREATE_METADATA_TEMPLATE; + const url = `${this.getMetadataTemplateUrl()}/schema`; + try { + const response = await this.xhr.post({ url, id: getTypedFileId(id), data: body }); + if (!this.isDestroyed()) { + successCallback(getProp(response, 'data')); + } + } catch (e) { + errorCallback(e, this.errorCode); + } + } + + /** + * Updates a namespaced metadata template via a list of patch operations. + * Maps to PUT /metadata_templates/{namespaceFqn}/{templateKey}/schema. + * + * @param {BoxItem} file - current file (typed id is used for token auth) + * @param {string} namespaceFqn - namespace FQN of the template + * @param {string} templateKey - template key + * @param {Array} patchItems - array of patch operations + * @param {Function} successCallback - called with the updated template on success + * @param {Function} errorCallback - called with error on failure + * @return {Promise} + */ + async updateMetadataTemplate( + file: BoxItem, + namespaceFqn: string, + templateKey: string, + patchItems: Array, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + // TODO(MDX-2136): remove next two lines when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) { + mockUpdateMetadataTemplate(file, namespaceFqn, templateKey, patchItems, successCallback); + return; + } + + const { id }: BoxItem = file; + this.errorCode = ERROR_CODE_UPDATE_METADATA_TEMPLATE; + const url = this.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); + try { + const response = await this.xhr.put({ + url, + id: getTypedFileId(id), + headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json' }, + data: patchItems, + }); + if (!this.isDestroyed()) { + successCallback(getProp(response, 'data')); + } + } catch (e) { + errorCallback(e, this.errorCode); + } + } + + /** + * Fetches a template schema in the shape expected by MetadataTemplateEditor. + * The Box API returns `hidden` for templates; the editor expects `isHidden`. + * + * @param {string} namespaceFqn - namespace FQN of the template + * @param {string} templateKey - template key + * @return {Promise} MetadataTemplateApiResponse shape for the editor + */ + async getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { + // TODO(MDX-2136): remove the mock block when namespace API is deployed. + // Falls through to the real API when the template is not found in the mock store. + if (IS_NAMESPACE_API_MOCKED) { + const mockResult = mockGetTemplateSchemaForEditor(namespaceFqn, templateKey); + if (mockResult) return mockResult; + } + + const url = this.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); + const response = await this.xhr.get({ url }); + const data = getProp(response, 'data', {}); + // Normalize: Box template API uses `hidden`; the editor's MetadataTemplateApiResponse expects `isHidden`. + return { + namespace: data.namespace || namespaceFqn, + templateKey: data.templateKey, + displayName: data.displayName, + fields: (data.fields || []).map(f => ({ + ...f, + isHidden: f.isHidden != null ? f.isHidden : f.hidden ?? false, + })), + isHidden: data.isHidden != null ? data.isHidden : data.hidden ?? false, + }; + } + /** * Returns the custom properties template * @@ -241,6 +518,7 @@ class Metadata extends File { return { id: uniqueId('metadata_template_'), scope: METADATA_SCOPE_GLOBAL, + namespace: METADATA_NAMESPACE_GLOBAL, templateKey: METADATA_TEMPLATE_PROPERTIES, hidden: false, fields: [], @@ -423,25 +701,34 @@ class Metadata extends File { } /** - * Gets metadata template schema by template key + * Gets metadata template schema by template key. + * + * In MIGRATION/FINAL mode, pass the full scope (e.g. `enterprise_123456`) + * or namespace FQN as `scope` so the correct URL path segment is used. + * Omitting `scope` falls back to the `enterprise` shorthand (SCOPED mode). * * @param {string} templateKey - template key + * @param {string} [scope] - scope or namespace FQN (defaults to `enterprise`) * @return {Promise} Promise object of metadata template */ - async getSchemaByTemplateKey(templateKey: string): Promise { + async getSchemaByTemplateKey( + templateKey: string, + scope?: string, + fileId?: string, + ): Promise { const cache: APICache = this.getCache(); const key = this.getMetadataTemplateSchemaCacheKey(templateKey); - // Return cached value if it exists if (cache.has(key)) { return cache.get(key); } - // Fetch from API if not cached - const url = this.getMetadataTemplateSchemaUrl(templateKey); - const response = await this.xhr.get({ url }); + const url = this.getMetadataTemplateSchemaUrl(templateKey, scope); + const response = await this.xhr.get({ + url, + ...(fileId && { id: getTypedFileId(fileId) }), + }); - // Cache the response cache.set(key, response); return response; @@ -573,10 +860,21 @@ class Metadata extends File { const instanceId = instance.$id; const templateKey = instance.$template; const scope = instance.$scope; - const template = templates.find(t => t.templateKey === templateKey && t.scope === scope); + const namespace = instance.$namespace; - // Enterprise scopes are always enterprise_XXXXX - if (!template && scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { + // Primary match: by scope (SCOPED mode; also works for enterprise-scoped + // instances in MIGRATION mode where $scope is still populated). + let template = templates.find(t => t.templateKey === templateKey && t.scope === scope); + + // 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); + } + + // Enterprise scopes are always enterprise_XXXXX; use optional chaining + // to guard against namespace-only instances where $scope is undefined. + if (!template && scope?.startsWith(METADATA_SCOPE_ENTERPRISE)) { // Any missing template is likely from another enterprise (e.g. collaborated file); // Templates array has no pagination so we can assume cross-enterprise as it contains all templates. const crossEnterpriseTemplates = await this.getTemplates(id, scope, instanceId, true); @@ -696,6 +994,7 @@ class Metadata extends File { id: template.id, fields, scope: template.scope, + namespace: template.namespace, templateKey: template.templateKey, type: instance.$type, }; @@ -750,6 +1049,30 @@ class Metadata extends File { return templateInstances; } + /** + * Extracts the full enterprise scope FQN (e.g. `enterprise_123456`) from a list + * of metadata instances. Used in MIGRATION/FINAL mode where the `'enterprise'` + * shorthand is no longer a valid template-fetch scope. + * + * Checks `$scope` first (populated in MIGRATION mode) and falls back to the + * leading `enterprise_XXXXX` segment of `$namespace` (used in FINAL mode). + * + * @param {Array} instances - raw metadata instances + * @return {string|null} full enterprise FQN or null when none found + */ + getEnterpriseScopeFromInstances(instances: Array): string | null { + for (const inst of instances) { + if (inst.$scope?.startsWith(METADATA_SCOPE_ENTERPRISE)) { + return inst.$scope; + } + if (inst.$namespace?.startsWith(METADATA_SCOPE_ENTERPRISE)) { + // Namespace FQNs may be "enterprise_123/key" — take the leading segment. + return inst.$namespace.split('/')[0]; + } + } + return null; + } + /** * API for getting metadata editors * @@ -805,11 +1128,29 @@ class Metadata extends File { try { const customPropertiesTemplate: MetadataTemplate = this.getCustomPropertiesTemplate(); - const [instances, globalTemplates, enterpriseTemplates] = await Promise.all([ - this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), - this.getTemplates(id, METADATA_SCOPE_GLOBAL), - hasMetadataFeature ? this.getTemplates(id, METADATA_SCOPE_ENTERPRISE) : Promise.resolve([]), - ]); + + // In namespace modes (MIGRATION/FINAL) the 'enterprise' shorthand is no longer + // valid. Fetch instances first so we can derive the full enterprise scope FQN + // (e.g. enterprise_123456) from the instances' $scope / $namespace fields. + // In SCOPED mode all three fetches run in parallel for performance. + let instances; + let globalTemplates; + let enterpriseTemplates; + if (this.metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED) { + [instances, globalTemplates] = await Promise.all([ + this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), + this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), + ]); + const enterpriseScope = this.getEnterpriseScopeFromInstances(instances); + enterpriseTemplates = + hasMetadataFeature && enterpriseScope ? await this.getTemplates(id, enterpriseScope) : []; + } else { + [instances, globalTemplates, enterpriseTemplates] = await Promise.all([ + this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), + this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), + hasMetadataFeature ? this.getTemplates(id, METADATA_SCOPE_ENTERPRISE) : Promise.resolve([]), + ]); + } // Filter out classification const filteredInstances = this.extractClassification(id, instances); @@ -900,7 +1241,11 @@ class Metadata extends File { try { if (!skills.data) { skills = await this.xhr.get({ - url: this.getMetadataUrl(id, METADATA_SCOPE_GLOBAL, METADATA_TEMPLATE_SKILLS), + url: this.getMetadataUrl( + id, + this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL), + METADATA_TEMPLATE_SKILLS, + ), id: getTypedFileId(id), }); } @@ -948,7 +1293,7 @@ class Metadata extends File { try { const metadata = await this.xhr.put({ - url: this.getMetadataUrl(id, METADATA_SCOPE_GLOBAL, METADATA_TEMPLATE_SKILLS), + url: this.getMetadataUrl(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL), METADATA_TEMPLATE_SKILLS), headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json', }, @@ -1010,8 +1355,16 @@ class Metadata extends File { const metadata = await this.xhr.put({ url: type === 'file' - ? this.getMetadataUrl(id, template.scope, template.templateKey) - : this.getMetadataUrlForFolder(id, template.scope, template.templateKey), + ? this.getMetadataUrl( + id, + this.getScopeOrNamespace(template.scope, template.namespace), + template.templateKey, + ) + : this.getMetadataUrlForFolder( + id, + this.getScopeOrNamespace(template.scope, template.namespace), + template.templateKey, + ), headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json', }, @@ -1123,7 +1476,11 @@ class Metadata extends File { try { await this.xhr.put({ - url: this.getMetadataUrl(id, templateInstance.scope, templateInstance.templateKey), + url: this.getMetadataUrl( + id, + this.getScopeOrNamespace(templateInstance.scope, templateInstance.namespace), + templateInstance.templateKey, + ), headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json', }, @@ -1182,7 +1539,8 @@ class Metadata extends File { const canEdit = !!permissions.can_upload; const isProperties = - template.templateKey === METADATA_TEMPLATE_PROPERTIES && template.scope === METADATA_SCOPE_GLOBAL; + template.templateKey === METADATA_TEMPLATE_PROPERTIES && + (template.scope === METADATA_SCOPE_GLOBAL || template.namespace === METADATA_NAMESPACE_GLOBAL); if (!canEdit || (is_externally_owned && !isProperties)) { errorCallback(getBadPermissionsError(), this.errorCode); @@ -1193,7 +1551,11 @@ class Metadata extends File { try { const metadata = await this.xhr.post({ - url: this.getMetadataUrl(id, template.scope, template.templateKey), + url: this.getMetadataUrl( + id, + this.getScopeOrNamespace(template.scope, template.namespace), + template.templateKey, + ), id: getTypedFileId(id), data: {}, }); @@ -1242,7 +1604,8 @@ class Metadata extends File { const canEdit = !!permissions.can_upload; const isProperties = - template.templateKey === METADATA_TEMPLATE_PROPERTIES && template.scope === METADATA_SCOPE_GLOBAL; + template.templateKey === METADATA_TEMPLATE_PROPERTIES && + (template.scope === METADATA_SCOPE_GLOBAL || template.namespace === METADATA_NAMESPACE_GLOBAL); if (!canEdit || (is_externally_owned && !isProperties)) { errorCallback(getBadPermissionsError(), this.errorCode); @@ -1315,7 +1678,11 @@ class Metadata extends File { } const metadata = await this.xhr.post({ - url: this.getMetadataUrl(id, template.scope, template.templateKey), + url: this.getMetadataUrl( + id, + this.getScopeOrNamespace(template.scope, template.namespace), + template.templateKey, + ), id: getTypedFileId(id), data: fieldsValues, }); @@ -1357,7 +1724,7 @@ class Metadata extends File { return; } - const { scope, templateKey }: MetadataTemplate = template; + const { scope, namespace, templateKey }: MetadataTemplate = template; const { id, permissions }: BoxItem = file; if (!id || !permissions) { @@ -1375,7 +1742,7 @@ class Metadata extends File { try { await this.xhr.delete({ - url: this.getMetadataUrl(id, scope, templateKey), + url: this.getMetadataUrl(id, this.getScopeOrNamespace(scope, namespace), templateKey), id: getTypedFileId(id), }); if (!this.isDestroyed()) { @@ -1385,14 +1752,18 @@ class Metadata extends File { if (isMetadataRedesign) { metadata.templateInstances.splice( metadata.templateInstances.findIndex( - instance => instance.scope === scope && instance.templateKey === templateKey, + instance => + instance.templateKey === templateKey && + (scope ? instance.scope === scope : instance.namespace === namespace), ), 1, ); } else { metadata.editors.splice( metadata.editors.findIndex( - editor => editor.template.scope === scope && editor.template.templateKey === templateKey, + editor => + editor.template.templateKey === templateKey && + (scope ? editor.template.scope === scope : editor.template.namespace === namespace), ), 1, ); diff --git a/src/api/metadataNamespaceMocks.js b/src/api/metadataNamespaceMocks.js new file mode 100644 index 0000000000..2b0517bff8 --- /dev/null +++ b/src/api/metadataNamespaceMocks.js @@ -0,0 +1,220 @@ +// @flow +// ─── TODO(MDX-2136): Namespace API mocks ───────────────────────────────────── +// Temporary stand-ins for the namespace-related Metadata APIs. +// +// To remove all mocks when the real API is deployed: +// 1. Delete this file. +// 2. Remove the import block from Metadata.js. +// 3. Remove the single `if (IS_NAMESPACE_API_MOCKED)` guard from each method. +// +// Template ids use the "fqn||templateKey" format so MetadataTemplateDropdown's +// handleEditTemplateById can parse namespaceFqn and templateKey unambiguously +// (templateKeys can contain underscores). Remove the `||` fallback in +// handleEditTemplateById alongside this file. +// ───────────────────────────────────────────────────────────────────────────── + +export const IS_NAMESPACE_API_MOCKED: boolean = true; + +// --------------------------------------------------------------------------- +// In-memory template store — mutated by mockCreateMetadataTemplate / +// mockUpdateMetadataTemplate so the browser reflects creates/edits without a +// real API call. +// --------------------------------------------------------------------------- + +const templateStore: { [key: string]: Array } = {}; + +function seedNamespace(fqn: string): void { + if (templateStore[fqn]) return; + + if (!fqn.includes('.')) { + // Root namespace: templates have both scope + namespace (MIGRATION mode §1.7.2) + templateStore[fqn] = [ + { + id: `${fqn}||productInfo`, + type: 'metadata_template', + scope: fqn, + namespace: fqn, + templateKey: 'productInfo', + displayName: 'Product Info', + hidden: false, + canEdit: true, + fields: [ + { type: 'string', key: 'category', displayName: 'Category', id: `${fqn}_f1` }, + { type: 'string', key: 'sku', displayName: 'SKU', id: `${fqn}_f2` }, + ], + }, + { + id: `${fqn}||contractDetails`, + type: 'metadata_template', + scope: fqn, + namespace: fqn, + templateKey: 'contractDetails', + displayName: 'Contract Details', + hidden: false, + canEdit: true, + fields: [ + { type: 'string', key: 'client', displayName: 'Client', id: `${fqn}_f3` }, + { + type: 'enum', + key: 'status', + displayName: 'Status', + id: `${fqn}_f4`, + options: [ + { key: 'active', id: `${fqn}_o1` }, + { key: 'expired', id: `${fqn}_o2` }, + ], + }, + ], + }, + ]; + } else { + // Child namespace: namespace-only templates (MIGRATION mode §1.7.2) + const childKey = (fqn.split('.').pop(): any); + const seeds: { [key: string]: Array } = { + legal: [ + { + id: `${fqn}||nda`, + type: 'metadata_template', + namespace: fqn, + templateKey: 'nda', + displayName: 'NDA', + hidden: false, + canEdit: true, + fields: [ + { type: 'string', key: 'counterparty', displayName: 'Counterparty', id: `${fqn}_lf1` }, + { type: 'date', key: 'expiryDate', displayName: 'Expiry Date', id: `${fqn}_lf2` }, + ], + }, + ], + hr: [ + { + id: `${fqn}||jobRequisition`, + type: 'metadata_template', + namespace: fqn, + templateKey: 'jobRequisition', + displayName: 'Job Requisition', + hidden: false, + canEdit: true, + fields: [ + { type: 'string', key: 'role', displayName: 'Role', id: `${fqn}_hf1` }, + { type: 'float', key: 'headcount', displayName: 'Headcount', id: `${fqn}_hf2` }, + ], + }, + ], + finance: [ + { + id: `${fqn}||invoiceDetails`, + type: 'metadata_template', + namespace: fqn, + templateKey: 'invoiceDetails', + displayName: 'Invoice Details', + hidden: false, + canEdit: true, + fields: [ + { type: 'float', key: 'amount', displayName: 'Amount (USD)', id: `${fqn}_ff1` }, + { type: 'date', key: 'dueDate', displayName: 'Due Date', id: `${fqn}_ff2` }, + ], + }, + ], + }; + templateStore[fqn] = seeds[childKey] ?? []; + } +} + +function getNamespaceTemplates(fqn: string): Array { + seedNamespace(fqn); + return templateStore[fqn]; +} + +// --------------------------------------------------------------------------- +// Exported mock functions — each mirrors the signature of its Metadata.js +// counterpart so the delegation is a single line. +// --------------------------------------------------------------------------- + +export function mockListNamespaces( + file: Object, + namespaceFqn: string, + params: Object, // eslint-disable-line no-unused-vars +): Promise<{ entries: Array, next_marker?: string }> { + if (!namespaceFqn.includes('.')) { + return Promise.resolve({ + entries: [ + { id: `${namespaceFqn}.legal`, displayName: 'Legal', canCreate: true }, + { id: `${namespaceFqn}.hr`, displayName: 'Human Resources', canCreate: true }, + { id: `${namespaceFqn}.finance`, displayName: 'Finance', canCreate: false }, + ], + next_marker: undefined, + }); + } + // Child namespaces are leaves in this mock — no further nesting. + return Promise.resolve({ entries: [], next_marker: undefined }); +} + +export function mockListTemplatesForNamespace( + file: Object, // eslint-disable-line no-unused-vars + namespaceFqn: string, + params: Object, // eslint-disable-line no-unused-vars +): Promise<{ entries: Array, next_marker?: string }> { + return Promise.resolve({ entries: getNamespaceTemplates(namespaceFqn), next_marker: undefined }); +} + +export function mockCreateMetadataTemplate(file: Object, body: Object, successCallback: Function): void { + const newTemplate = { + id: `${body.namespace}||${body.templateKey || 'template'}`, + type: 'metadata_template', + namespace: body.namespace, + templateKey: body.templateKey, + displayName: body.displayName || body.templateKey, + hidden: body.hidden ?? false, + canEdit: true, + fields: body.fields || [], + }; + getNamespaceTemplates(body.namespace).push(newTemplate); + successCallback(newTemplate); +} + +export function mockUpdateMetadataTemplate( + file: Object, // eslint-disable-line no-unused-vars + namespaceFqn: string, + templateKey: string, + patchItems: Array, + successCallback: Function, +): void { + const templates = getNamespaceTemplates(namespaceFqn); + const idx = templates.findIndex(t => t.templateKey === templateKey); + if (idx !== -1) { + let updated = { ...templates[idx] }; + patchItems.forEach(op => { + if (!op || typeof op !== 'object') return; + const { op: operation, path, value } = op; + if (operation === 'replace') { + if (path === '/displayName') updated = { ...updated, displayName: value }; + else if (path === '/hidden') updated = { ...updated, hidden: value }; + else if (path === '/fields') updated = { ...updated, fields: value }; + } else if (operation === 'add' && path === '/fields/-') { + updated = { ...updated, fields: [...(updated.fields || []), value] }; + } + }); + templates[idx] = updated; + } + successCallback({ type: 'metadata_template', namespace: namespaceFqn, templateKey }); +} + +/** + * Returns a MetadataTemplateApiResponse-shaped object for the editor modal, + * or null if the template is not in the mock store (caller falls through to real API). + */ +export function mockGetTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Object | null { + const tmpl = getNamespaceTemplates(namespaceFqn).find(t => t.templateKey === templateKey); + if (!tmpl) return null; + return { + namespace: tmpl.namespace || namespaceFqn, + templateKey: tmpl.templateKey, + displayName: tmpl.displayName, + fields: (tmpl.fields || []).map(f => ({ + ...f, + isHidden: f.isHidden != null ? f.isHidden : f.hidden ?? false, + })), + isHidden: tmpl.isHidden != null ? tmpl.isHidden : tmpl.hidden ?? false, + }; +} diff --git a/src/common/types/api.js b/src/common/types/api.js index b314df488c..cf747291ba 100644 --- a/src/common/types/api.js +++ b/src/common/types/api.js @@ -99,6 +99,7 @@ type APIOptions = { * a future major version. */ metadataApiHost?: string, + metadataNamespaceMode?: string, requestInterceptor?: Function, responseInterceptor?: Function, retryableStatusCodes?: Array, diff --git a/src/common/types/metadata.js b/src/common/types/metadata.js index ba99410e39..74cff75fa5 100644 --- a/src/common/types/metadata.js +++ b/src/common/types/metadata.js @@ -49,7 +49,8 @@ type MetadataTemplate = { hidden?: boolean, id: string, isHidden?: boolean, - scope: string, // V2 + namespace?: string, // MIGRATION/FINAL modes + scope: string, // V2; absent for namespace-only templates in FINAL mode templateKey: string, // V3 }; @@ -115,8 +116,9 @@ type MetadataInstance = { type MetadataInstanceV2 = { $canEdit: boolean, $id: string, + $namespace?: string, // MIGRATION/FINAL modes $parent: string, - $scope: string, + $scope?: string, // absent for namespace-only instances in MIGRATION/FINAL modes $template: string, $type: string, $typeVersion: number, @@ -214,7 +216,8 @@ type MetadataTemplateInstance = { hidden?: boolean, id: string, fields: MetadataTemplateInstanceField[], - scope: string, + namespace?: string, + scope?: string, templateKey: string, type: string, }; diff --git a/src/constants.js b/src/constants.js index e1c9e44766..5397516d85 100644 --- a/src/constants.js +++ b/src/constants.js @@ -82,6 +82,7 @@ export const HEADER_RETRY_AFTER = 'Retry-After'; export const HEADER_CLIENT_NAME = 'X-Box-Client-Name'; export const HEADER_CLIENT_VERSION = 'X-Box-Client-Version'; export const HEADER_ACCEPT_LANGUAGE = 'Accept-Language'; +export const HEADER_BOX_VERSION = 'box-version'; /* ------------------ Metadata ---------------------- */ export const KEY_CLASSIFICATION_TYPE = 'Box__Security__Classification__Key'; @@ -91,6 +92,13 @@ export const METADATA_TEMPLATE_SKILLS = 'boxSkillsCards'; export const METADATA_TEMPLATE_PROPERTIES = 'properties'; export const METADATA_SCOPE_GLOBAL = 'global'; export const METADATA_SCOPE_ENTERPRISE = 'enterprise'; +export const METADATA_NAMESPACE_GLOBAL = 'box.metadata'; +export const METADATA_SCOPE_MODE_SCOPED = 'SCOPED'; +export const METADATA_SCOPE_MODE_MIGRATION = 'MIGRATION'; +export const METADATA_SCOPE_MODE_FINAL = 'FINAL'; +// Field names within enterprise_configurations.content_and_sharing that signal namespace migration stage. +export const METADATA_NAMESPACE_MIGRATION_FIELD = 'is_scoped_templates_migration_enabled'; +export const METADATA_NAMESPACE_FINAL_FIELD = 'are_namespaced_metadata_templates_enabled'; export const METADATA_TEMPLATE_FETCH_LIMIT = API_PAGE_LIMIT; export const METADATA_SUGGESTIONS_CONFIDENCE_EXPERIMENTAL = 'experimental'; export const SUCCESS_CODE_UPDATE_METADATA_TEMPLATE_INSTANCE = 'update_metadata_template_instance_success'; @@ -323,6 +331,7 @@ export const ERROR_CODE_CREATE_TASK_ASSIGNMENT = 'create_task_assignment_error'; export const ERROR_CODE_CREATE_TASK_COLLABORATOR = 'create_task_collaborator_error'; export const ERROR_CODE_CREATE_FOLDER = 'create_folder_error'; export const ERROR_CODE_CREATE_METADATA = 'create_metadata_error'; +export const ERROR_CODE_CREATE_METADATA_TEMPLATE = 'create_metadata_template_error'; export const ERROR_CODE_DELETE_APP_ACTIVITY = 'delete_app_activity_error'; export const ERROR_CODE_DELETE_ANNOTATION = 'delete_annotation_error'; export const ERROR_CODE_EDIT_ANNOTATION = 'edit_annotation_error'; @@ -342,6 +351,7 @@ export const ERROR_CODE_UPDATE_TASK_COLLABORATOR = 'update_task_collaborator_err export const ERROR_CODE_UPDATE_COMMENT = 'update_comment_error'; export const ERROR_CODE_UPDATE_SKILLS = 'update_skills_error'; export const ERROR_CODE_UPDATE_METADATA = 'update_metadata_error'; +export const ERROR_CODE_UPDATE_METADATA_TEMPLATE = 'update_metadata_template_error'; export const ERROR_CODE_GET_DOWNLOAD_URL = 'get_download_url_error'; export const ERROR_CODE_RENAME_ITEM = 'rename_item_error'; export const ERROR_CODE_SHARE_ITEM = 'share_item_error'; diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index 7d870531ed..d40650f9e9 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -102,11 +102,11 @@ const MetadataInstanceEditor: React.FC = ({ taxonomyOptionsFetcher={taxonomyOptionsFetcher} isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} - isBoundingBoxEnabled={isBoundingBoxEnabled} onSelectMetadataField={onSelectMetadataField} selectedMetadataFieldId={selectedMetadataFieldId} customRef={customRef} trackEvent={trackEvent} + isBoundingBoxEnabled={isBoundingBoxEnabled} /> ); }; diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index da9f472e4e..5add1912a9 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -8,7 +8,6 @@ import { FormattedMessage, useIntl } from 'react-intl'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { InlineError, LoadingIndicator } from '@box/blueprint-web'; import { - AddMetadataTemplateDropdown, AutofillContextProvider, FilterInstancesDropdown, MetadataEmptyState, @@ -18,6 +17,11 @@ import { type MetadataTemplate, type MetadataTemplateInstance, } from '@box/metadata-editor'; +import { + type MetadataTemplateApiResponse, + type MetadataTemplateCreateBody, + type MetadataTemplatePatchItem, +} from '@box/metadata-template-editor'; import { TreeQueryInput } from '@box/combobox-with-api'; import type { GetPreviewForMetadataReturnType } from './types/BoxAISidebarTypes'; @@ -31,6 +35,8 @@ import { ORIGIN_METADATA_SIDEBAR_REDESIGN, SIDEBAR_VIEW_METADATA, ERROR_CODE_METADATA_STRUCTURED_TEXT_REP, + METADATA_SCOPE_ENTERPRISE, + METADATA_SCOPE_MODE_SCOPED, } from '../../constants'; import { EVENT_JS_READY } from '../common/logger/constants'; import { mark } from '../../utils/performance'; @@ -41,6 +47,7 @@ import { type WithLoggerProps } from '../../common/types/logging'; import messages from '../common/messages'; import './MetadataSidebarRedesign.scss'; import MetadataInstanceEditor from './MetadataInstanceEditor'; +import MetadataTemplateDropdown from './MetadataTemplateDropdown'; import { convertTemplateToTemplateInstance } from './utils/convertTemplateToTemplateInstance'; import { isExtensionSupportedForMetadataSuggestions } from './utils/isExtensionSupportedForMetadataSuggestions'; import { @@ -52,6 +59,9 @@ import { import { useMetadataSidebarFilteredTemplates } from './hooks/useMetadataSidebarFilteredTemplates'; import useMetadataFieldSelection from './hooks/useMetadataFieldSelection'; import useMetadataSidebarUnsavedChangesGuard from './hooks/useMetadataSidebarUnsavedChangesGuard'; +import useMetadataTemplateEditor from './hooks/useMetadataTemplateEditor'; +import useMetadataTemplateItemsService from './hooks/useMetadataTemplateItemsService'; +import useMetadataNamespaceMode from './hooks/useMetadataNamespaceMode'; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}_${EVENT_JS_READY}`; @@ -125,6 +135,7 @@ function MetadataSidebarRedesign({ const isDeleteConfirmationModalCheckboxEnabled: boolean = useFeatureEnabled( 'metadata.deleteConfirmationModalCheckbox.enabled', ); + const isConfidenceScoreReviewEnabled: boolean = useFeatureEnabled('metadata.confidenceScore.enabled'); const isBoundingBoxEnabled = useFeatureEnabled('metadata.boundingBox.enabled'); @@ -137,6 +148,7 @@ function MetadataSidebarRedesign({ handleCreateMetadataInstance, handleDeleteMetadataInstance, handleUpdateMetadataInstance, + refetchMetadata, templates, extractErrorCode, errorMessage, @@ -163,6 +175,35 @@ function MetadataSidebarRedesign({ const [appliedTemplateInstances, setAppliedTemplateInstances] = useState>(templateInstances); const [pendingTemplateToEdit, setPendingTemplateToEdit] = useState(null); + + // Template management — gated behind namespace migration mode (MIGRATION or FINAL). + const [isDropdownOpen, setIsDropdownOpen] = useState(undefined); + + // Real enterprise FQN (e.g. "enterprise_123456") derived from the already-loaded templates. + // The numeric ID (without the "enterprise_" prefix) is used for the enterprise configurations API. + const enterpriseId = templates.find(t => t.scope?.startsWith(`${METADATA_SCOPE_ENTERPRISE}_`))?.scope; + const enterpriseNumericId = enterpriseId?.slice(METADATA_SCOPE_ENTERPRISE.length + 1); + + // Fetch the migration mode from the enterprise configurations API. + // Gated behind the enterprise_metadata_namespaces_opt_in split treatment — when the + // flag is off the hook skips the API call and returns null (= legacy SCOPED behaviour). + const isNamespacesOptInEnabled: boolean = useFeatureEnabled('metadata.namespacesOptIn.enabled'); + const { mode: metadataNamespaceMode } = useMetadataNamespaceMode( + file, + api, + enterpriseNumericId, + isNamespacesOptInEnabled, + ); + const isTemplateManagementEnabled = !!metadataNamespaceMode && metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED; + + // API-backed ItemsService for MetadataTemplateBrowser — only active when template management is enabled. + const itemsService = useMetadataTemplateItemsService( + api, + file, + isTemplateManagementEnabled ? enterpriseId : undefined, + templates ?? [], + ); + const { handleUnsavedChangesModalOpen, pendingNavLocation, setPendingNavLocation, unblockRouterHistory } = useMetadataSidebarUnsavedChangesGuard({ editingTemplate, @@ -221,6 +262,7 @@ function MetadataSidebarRedesign({ const handleTemplateSelect = (selectedTemplate: MetadataTemplate) => { clearExtractError(); + setIsDropdownOpen(false); if (editingTemplate) { setPendingTemplateToEdit(convertTemplateToTemplateInstance(file, selectedTemplate)); @@ -231,6 +273,79 @@ function MetadataSidebarRedesign({ } }; + const handleCreateTemplate = useCallback( + (body: MetadataTemplateCreateBody) => + new Promise((resolve, reject) => { + api.getMetadataAPI(false).createMetadataTemplate( + file, + body, + () => { + refetchMetadata(); + resolve(); + }, + (error: Error, code: string) => { + onError(error, code); + reject(error); + }, + ); + }), + [api, file, onError, refetchMetadata], + ); + const handleEditTemplate = useCallback( + (patchItems: MetadataTemplatePatchItem[], identifier: { namespaceFQN: string; templateKey: string }) => + new Promise((resolve, reject) => { + api.getMetadataAPI(false).updateMetadataTemplate( + file, + identifier.namespaceFQN, + identifier.templateKey, + patchItems, + () => { + refetchMetadata(); + resolve(); + }, + (error: Error, code: string) => { + onError(error, code); + reject(error); + }, + ); + }), + [api, file, onError, refetchMetadata], + ); + + const { + openCreate, + openEdit, + modal: templateEditorModal, + } = useMetadataTemplateEditor({ + onCreate: handleCreateTemplate, + onEdit: handleEditTemplate, + }); + + // Opens the template editor in create mode — also dismisses the dropdown popover. + const handleOpenCreateEditor = useCallback( + (namespaceFqn: string) => { + setIsDropdownOpen(false); + openCreate(namespaceFqn); + }, + [openCreate], + ); + + // Opens the template editor in edit mode — also dismisses the dropdown popover. + const handleOpenEditEditor = useCallback( + ({ namespaceFqn, templateKey }: { namespaceFqn: string; templateKey: string }) => { + setIsDropdownOpen(false); + openEdit({ + namespaceFqn, + templateKey, + fetchTemplate: () => + api + .getMetadataAPI(false) + .getTemplateSchemaForEditor(namespaceFqn, templateKey) as Promise, + }); + }, + [openEdit, api], + ); + const handleCancel = () => { clearExtractError(); setEditingTemplate(null); @@ -299,10 +414,18 @@ function MetadataSidebarRedesign({ const canEdit = !!file?.permissions?.can_upload; const metadataDropdown = canEdit && isSuccess && templates && ( - ); @@ -366,79 +489,84 @@ function MetadataSidebarRedesign({ }, [createSessionRequest, fileId]); return ( - -
- {errorMessageDisplay} - {isLoading && } - {showEmptyState && ( - - )} - - {editingTemplate && ( - setShouldShowOnlyReviewFields(!shouldShowOnlyReviewFields)} - setIsUnsavedChangesModalOpen={handleUnsavedChangesModalOpen} - shouldShowOnlyReviewFields={shouldShowOnlyReviewFields} - taxonomyOptionsFetcher={taxonomyOptionsFetcher} - template={editingTemplate} - isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} - isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} - isBoundingBoxEnabled={isBoundingBoxEnabled} - onSelectMetadataField={handleSelectMetadataField} - selectedMetadataFieldId={selectedMetadataFieldId} - trackEvent={trackEvent} - /> - )} - {showList && ( - { - setEditingTemplate(templateInstance); - setIsDeleteButtonDisabled(false); - setShouldShowOnlyReviewFields(shouldEnableReviewFilter); - }} - onSelectMetadataField={handleSelectMetadataField} - selectedMetadataFieldId={selectedMetadataFieldId} - templateInstances={templateInstancesList} - taxonomyNodeFetcher={taxonomyNodeFetcher} - isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} - isBoundingBoxEnabled={isBoundingBoxEnabled} - trackEvent={trackEvent} + <> + {templateEditorModal} + +
+ {errorMessageDisplay} + {isLoading && } + {showEmptyState && ( + )} - -
-
+ + {editingTemplate && ( + setShouldShowOnlyReviewFields(!shouldShowOnlyReviewFields)} + setIsUnsavedChangesModalOpen={handleUnsavedChangesModalOpen} + shouldShowOnlyReviewFields={shouldShowOnlyReviewFields} + taxonomyOptionsFetcher={taxonomyOptionsFetcher} + template={editingTemplate} + isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} + isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} + isBoundingBoxEnabled={isBoundingBoxEnabled} + onSelectMetadataField={handleSelectMetadataField} + selectedMetadataFieldId={selectedMetadataFieldId} + trackEvent={trackEvent} + /> + )} + {showList && ( + { + setEditingTemplate(templateInstance); + setIsDeleteButtonDisabled(false); + setShouldShowOnlyReviewFields(shouldEnableReviewFilter); + }} + onSelectMetadataField={handleSelectMetadataField} + selectedMetadataFieldId={selectedMetadataFieldId} + templateInstances={templateInstancesList} + taxonomyNodeFetcher={taxonomyNodeFetcher} + isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} + trackEvent={trackEvent} + /> + )} + +
+
+ ); } diff --git a/src/elements/content-sidebar/MetadataTemplateDropdown.tsx b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx new file mode 100644 index 0000000000..222632ad50 --- /dev/null +++ b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx @@ -0,0 +1,114 @@ +/** + * @file Variant-picking wrapper for the metadata template dropdown. + * + * Composes the BUE-owned `itemsService` (data) and `eventService` + * (side-effects) for the template-management variant, and selects between + * that and the legacy static-list variant based on + * `isMetadataTemplateManagementEnabled`. + * + * The metadata-editor package owns only UI; this file owns the wiring. + */ +import React, { useCallback } 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 useMetadataTemplateEventService from './hooks/useMetadataTemplateEventService'; + +export interface MetadataTemplateDropdownProps { + templates: EditorMetadataTemplate[]; + selectedTemplates: EditorMetadataTemplate[]; + enterpriseId: string | undefined; + itemsService: ItemsService | undefined; + onSelect: (template: EditorMetadataTemplate) => void; + 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`. + */ + onEditTemplate?: (args: { namespaceFqn: string; templateKey: string }) => void; + /** Whether template creation is allowed at the enterprise root namespace. */ + canCreateAtRoot?: boolean; + /** + * Controlled open state for the dropdown popover. When provided together + * with `onOpenChange`, the host owns visibility — used to dismiss the + * popover when escalating to the template editor modal. + */ + open?: boolean; + /** Called whenever the popover proposes a new open state. */ + onOpenChange?: (open: boolean) => void; +} + +export default function MetadataTemplateDropdown({ + canCreateAtRoot, + enterpriseId, + isMetadataTemplateManagementEnabled, + itemsService, + onCreateTemplate, + onEditTemplate, + onOpenChange, + onSelect, + open, + selectedTemplates, + templates, +}: Readonly) { + // Bridge: native 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const namespaceFqn = template.scope ?? (template as any).namespace; + if (namespaceFqn) { + onEditTemplate({ namespaceFqn, templateKey: template.templateKey }); + return; + } + } + // Fallback: mock template ids are encoded as "fqn||templateKey". + // This handles child-namespace templates and newly created mock templates + // that aren't yet in the editor templates list. + 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 }); + } + } + }, + [templates, onEditTemplate], + ); + + const eventService = useMetadataTemplateEventService({ + templates, + onSelect, + onCreateTemplate: isMetadataTemplateManagementEnabled ? onCreateTemplate : undefined, + onEditTemplate: isMetadataTemplateManagementEnabled && onEditTemplate ? handleEditTemplateById : undefined, + }); + + if (isMetadataTemplateManagementEnabled && enterpriseId && itemsService) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 2e8a9b7b1f..7fa8b96452 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -155,6 +155,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); }); @@ -181,6 +182,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -285,6 +287,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -310,6 +313,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.ERROR, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); const errorMessage = { id: 'error', defaultMessage: 'error message' }; @@ -332,6 +336,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.LOADING, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -378,6 +383,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -402,6 +408,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -428,6 +435,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent({}, { 'metadata.deleteConfirmationModalCheckbox.enabled': true }); @@ -458,6 +466,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -486,6 +495,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -507,6 +517,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); const filteredTemplateIds = [mockVisibleTemplateInstance.id]; @@ -531,6 +542,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); const filteredTemplateIds = ['non-existing-template-id']; @@ -671,6 +683,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); }; diff --git a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts new file mode 100644 index 0000000000..d674010896 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react'; +import API from '../../../api'; +import type { BoxItem } from '../../../common/types/core'; + +/** Mirrors the METADATA_SCOPE_MODE_* constants from constants.js as a strict union. */ +export type MetadataScopeMode = 'SCOPED' | 'MIGRATION' | 'FINAL'; + +interface UseMetadataNamespaceModeReturn { + /** + * The migration mode returned by the enterprise configurations API, + * or `null` while loading / if the request fails. + */ + mode: MetadataScopeMode | null; + /** True while the enterprise configurations request is in-flight. */ + isLoading: boolean; +} + +/** + * Fetches the metadata namespace migration mode for the given enterprise from + * `GET /2.0/enterprise_configurations/{enterpriseNumericId}`. + * + * The fetch is deferred until `enterpriseNumericId` is known (i.e. after + * templates are loaded). Cancels any in-flight request when the component + * unmounts or the enterprise ID changes. + * + * When `isEnabled` is `false` the hook skips the API call entirely and returns + * `{ mode: null, isLoading: false }`, keeping the UI in legacy SCOPED mode. + * Pass the `enterprise_metadata_namespaces_opt_in` split treatment result here. + * + * Returns `{ mode: null, isLoading: false }` when `enterpriseNumericId` is + * undefined — callers should treat this as "not yet known" and keep the UI + * in its default (non-browser) state. + * + * @example + * const { mode, isLoading } = useMetadataNamespaceMode(api, enterpriseNumericId, isEnabled); + * const isTemplateManagementEnabled = + * !!mode && mode !== METADATA_SCOPE_MODE_SCOPED; + */ +export default function useMetadataNamespaceMode( + file: BoxItem, + api: API, + enterpriseNumericId: string | undefined, + isEnabled: boolean = false, +): UseMetadataNamespaceModeReturn { + const [mode, setMode] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const cancelledRef = useRef(false); + + useEffect(() => { + if (!isEnabled || !file || !enterpriseNumericId) { + setMode(null); + setIsLoading(false); + return undefined; + } + + cancelledRef.current = false; + setIsLoading(true); + + const metadataAPI = api.getMetadataAPI(false); + metadataAPI.getMetadataNamespaceMode(file, enterpriseNumericId).then(resolvedMode => { + if (!cancelledRef.current) { + setMode(resolvedMode); + setIsLoading(false); + } + }); + + return () => { + cancelledRef.current = true; + }; + }, [api, file, enterpriseNumericId, isEnabled]); + + return { mode, isLoading }; +} diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateEditor.tsx b/src/elements/content-sidebar/hooks/useMetadataTemplateEditor.tsx new file mode 100644 index 0000000000..c714f5ab4f --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateEditor.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useState } from 'react'; +import { + MetadataTemplateEditorMode, + MetadataTemplateEditorModal, + type MetadataTemplateApiResponse, + type MetadataTemplateCreateBody, + type MetadataTemplatePatchItem, +} from '@box/metadata-template-editor'; + +type ClosedState = { status: 'closed' }; +type CreateState = { status: 'create'; namespace: string }; +type EditState = { + status: 'edit'; + namespaceFqn: string; + templateKey: string; + fetchTemplate: () => Promise; +}; +type EditorState = ClosedState | CreateState | EditState; + +interface UseMetadataTemplateEditorArgs { + /** + * Called after the user submits a valid create form. Receives the API-ready + * payload — the host owns the network call. The modal closes automatically + * after the promise resolves. + */ + onCreate: (body: MetadataTemplateCreateBody) => void | Promise; + /** + * Called after the user saves changes to an existing template. Receives the + * JSON-patch array and the template identifier — the host owns the network + * call. The modal closes automatically after the promise resolves. + */ + onEdit: ( + patchItems: MetadataTemplatePatchItem[], + identifier: { namespaceFQN: string; templateKey: string }, + ) => void | Promise; +} + +export interface UseMetadataTemplateEditorReturn { + /** Opens the editor in create mode for the given namespace FQN. */ + openCreate: (namespaceFqn: string) => void; + /** Opens the editor in edit mode for the given namespace + templateKey. */ + openEdit: (args: { + namespaceFqn: string; + templateKey: string; + fetchTemplate: () => Promise; + }) => void; + /** + * The modal JSX to render somewhere stable in the tree (e.g. beside + * `SidebarContent`). `null` when the editor is closed. + */ + modal: React.ReactNode; +} + +/** + * Manages the lifecycle of the `MetadataTemplateEditorModal` for the metadata + * sidebar — open/closed state, which mode (create / edit) is active, and the + * submit-and-close flow. + * + * @example + * const { openCreate, openEdit, modal } = useMetadataTemplateEditor({ + * onCreate: handleCreateTemplate, + * onEdit: handleEditTemplate, + * }); + */ +export default function useMetadataTemplateEditor({ + onCreate, + onEdit, +}: UseMetadataTemplateEditorArgs): UseMetadataTemplateEditorReturn { + const [state, setState] = useState({ status: 'closed' }); + + const close = useCallback(() => setState({ status: 'closed' }), []); + + const openCreate = useCallback((namespace: string) => { + setState({ status: 'create', namespace }); + }, []); + + const openEdit = useCallback( + ({ + namespaceFqn, + templateKey, + fetchTemplate, + }: { + namespaceFqn: string; + templateKey: string; + fetchTemplate: () => Promise; + }) => { + setState({ status: 'edit', namespaceFqn, templateKey, fetchTemplate }); + }, + [], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) close(); + }, + [close], + ); + + const handleCreate = useCallback( + async (body: MetadataTemplateCreateBody) => { + await onCreate(body); + close(); + }, + [onCreate, close], + ); + + const handleEdit = useCallback( + async (patchItems: MetadataTemplatePatchItem[], identifier: { namespaceFQN: string; templateKey: string }) => { + await onEdit(patchItems, identifier); + close(); + }, + [onEdit, close], + ); + + let modal: React.ReactNode = null; + + if (state.status === 'create') { + modal = ( + + ); + } else if (state.status === 'edit') { + modal = ( + + ); + } + + return { openCreate, openEdit, modal }; +} diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts new file mode 100644 index 0000000000..3d37bcf5c1 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import type { EventService, MetadataTemplate as BrowserMetadataTemplate } from '@box/metadata-template-browser'; +import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; + +interface UseMetadataTemplateEventServiceArgs { + /** + * Editor-shape templates from `useSidebarMetadataFetcher`. Primary lookup pool + * for resolving a browser-shape template back to editor-shape on selection. + */ + templates: EditorMetadataTemplate[]; + /** Invoked with the editor-shape template when the user selects one in the browser. */ + onSelect: (template: EditorMetadataTemplate) => void; + /** Opens the template editor modal for creating a new template in the given namespace. */ + onCreateTemplate?: (namespaceFqn: string) => void; + /** + * Called when the user clicks the edit affordance on a template in the browser. + * Receives the native template `id`; the host resolves `namespaceFqn` and `templateKey` + * by looking up in `templates`. + */ + onEditTemplate?: (templateId: string) => void; +} + +/** + * Builds the side-effects `EventService` consumed by `MetadataTemplateBrowser`. + * + * Owns the browser-shape → editor-shape bridge: the browser emits its own + * `MetadataTemplate` shape on `onTemplateSelect`, but downstream sidebar code + * (e.g. `convertTemplateToTemplateInstance`) requires the editor shape — so we + * resolve by id-lookup in `templates`. + * + * @example + * const eventService = useMetadataTemplateEventService({ + * templates, + * onSelect: handleTemplateSelect, + * onCreateTemplate: handleOpenCreateEditor, + * onEditTemplate: handleEditTemplateById, + * }); + */ +export default function useMetadataTemplateEventService({ + templates, + onSelect, + onCreateTemplate, + onEditTemplate, +}: UseMetadataTemplateEventServiceArgs): EventService { + return useMemo( + () => ({ + onTemplateSelect: async (browserTemplate: BrowserMetadataTemplate) => { + // Primary: exact id match (production path — both lists share the same API ids). + // Fallback: templateKey + scope/namespace match for cases where the browser + // returns a different id shape than the editor list (e.g. during mock dev). + const editorTemplate = + templates.find(t => t.id === browserTemplate.id) ?? + templates.find( + t => + t.templateKey === browserTemplate.templateKey && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (t.scope === browserTemplate.scope || (t as any).namespace === browserTemplate.scope), + ); + if (editorTemplate) { + onSelect(editorTemplate); + } + }, + ...(onCreateTemplate && { onCreateTemplate }), + ...(onEditTemplate && { onTemplateEdit: onEditTemplate }), + }), + [templates, onSelect, onCreateTemplate, onEditTemplate], + ); +} diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts new file mode 100644 index 0000000000..45b1e70281 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -0,0 +1,130 @@ +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { + type FetchParams, + type FetchResponse, + type ItemsService, + type MetadataTemplate as BrowserMetadataTemplate, + type MetadataNamespace, +} from '@box/metadata-template-browser'; +import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; + +import API from '../../../api'; +import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import messages from '../../../features/metadata-instance-editor/messages'; +import type { BoxItem } from '../../../common/types/core'; + +function resolveDisplayName(template: EditorMetadataTemplate, customMetadataName: string): string { + if (template.templateKey === METADATA_TEMPLATE_PROPERTIES) { + return customMetadataName; + } + return template.displayName || template.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. + * + * Returns `undefined` when `enterpriseFqn` is not yet known (templates still loading). + * + * @example + * const itemsService = useMetadataTemplateItemsService(api, enterpriseFqn, templates); + */ +export default function useMetadataTemplateItemsService( + api: API, + file: BoxItem, + enterpriseFqn: string | undefined, + templates: EditorMetadataTemplate[], +): ItemsService | undefined { + const { formatMessage } = useIntl(); + const customMetadataName = formatMessage(messages.customTitle); + + return useMemo(() => { + if (!enterpriseFqn) { + 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.map(t => ({ + id: t.id, + type: t.type, + displayName: resolveDisplayName(t, customMetadataName), + scope: t.scope, + templateKey: t.templateKey, + canEdit: t.canEdit, + hidden: t.hidden, + })); + + return { + getNamespaces: async ( + namespaceFQN: string, + params: FetchParams, + ): Promise> => { + const result = await api + .getMetadataAPI(false) + .listNamespaces(file, namespaceFQN, { limit: params.limit, marker: params.marker }); + return result as FetchResponse; + }, + + getTemplates: async ( + namespaceFQN: string, + params: FetchParams, + ): Promise> => { + const result = await api + .getMetadataAPI(false) + .listTemplatesForNamespace(file, namespaceFQN, { limit: params.limit, marker: params.marker }); + // Map the raw API response to the browser-expected shape. + // The API returns camelCase fields; we normalise displayName and scope/namespace. + const entries: BrowserMetadataTemplate[] = (result.entries ?? []).map((t: Record) => { + const templateKey = t.templateKey as string; + const templateScope = (t.namespace as string) ?? (t.scope as string) ?? namespaceFQN; + // Prefer the editor template's id so that id-based lookups in + // handleEditTemplateById / onTemplateSelect resolve correctly. + // Falls back to the raw API id when there is no matching editor template + // (e.g. child-namespace-only templates not yet in the editor list). + const editorMatch = templates.find( + et => + et.templateKey === templateKey && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (et.scope === templateScope || (et as any).namespace === templateScope), + ); + return { + id: editorMatch?.id ?? (t.id as string), + type: (t.type as string) ?? 'metadata_template', + displayName: ((t.displayName as string) ?? templateKey) || '', + scope: templateScope, + templateKey, + canEdit: (t.canEdit as boolean) ?? false, + hidden: (t.hidden as boolean) ?? false, + }; + }); + return { entries, next_marker: result.next_marker }; + }, + + getSearchResults: async ( + 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, + }; + }, + }; + }, [api, file, enterpriseFqn, templates, customMetadataName]); +} diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index 770d85959d..a597ced8d4 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -59,6 +59,8 @@ interface DataFetcher { JSONPatch: Array, successCallback: () => void, ) => Promise; + /** Re-fetches metadata (templates + instances) using the current file. */ + refetchMetadata: () => void; status: STATUS; templateInstances: Array; templates: Array; @@ -281,7 +283,9 @@ function useSidebarMetadataFetcher( return []; } - const templateInstance = templates.find(template => template.templateKey === templateKey && template.scope); + const templateInstance = templates.find( + template => template.templateKey === templateKey && (template.scope || template.namespace), + ); const fields = templateInstance?.fields || []; return fields.map(field => { @@ -328,12 +332,19 @@ function useSidebarMetadataFetcher( } }, [api, fetchFileErrorCallback, fetchFileSuccessCallback, fileId, status]); + const refetchMetadata = React.useCallback(() => { + if (file) { + fetchMetadata(file); + } + }, [file, fetchMetadata]); + return { clearExtractError: () => setExtractErrorCode(null), extractSuggestions, handleCreateMetadataInstance, handleDeleteMetadataInstance, handleUpdateMetadataInstance, + refetchMetadata, extractErrorCode, errorMessage, file, diff --git a/yarn.lock b/yarn.lock index 418daf8071..dfe85805f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1527,6 +1527,37 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@dnd-kit/accessibility@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af" + integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.1.0": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.3.1.tgz#4c36406a62c7baac499726f899935f93f0e6d003" + integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ== + dependencies: + "@dnd-kit/accessibility" "^3.1.1" + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/sortable@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-8.0.0.tgz#086b7ac6723d4618a4ccb6f0227406d8a8862a96" + integrity sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g== + dependencies: + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b" + integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg== + dependencies: + tslib "^2.0.0" + "@dual-bundle/import-meta-resolve@^4.2.1": version "4.2.1" resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz#cd0b25b3808cd9e684cd6cd549bbf8e1dcf05ee7" @@ -16021,6 +16052,11 @@ properties-parser@^0.3.1: dependencies: string.prototype.codepointat "^0.2.0" +property-expr@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" + integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== + property-information@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" @@ -18708,6 +18744,11 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" @@ -18801,6 +18842,11 @@ toidentifier@~1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + totalist@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" @@ -18987,7 +19033,7 @@ type-fest@^1.0.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.12.2: +type-fest@^2.12.2, type-fest@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -20035,6 +20081,16 @@ yoctocolors@^2.0.0: resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.1.tgz#e0167474e9fbb9e8b3ecca738deaa61dd12e56fc" integrity sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ== +yup@^1.6.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.7.1.tgz#4c47c6bb367df08d4bc597f8c4c4f5fc4277f6ab" + integrity sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0" + zoom-level@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/zoom-level/-/zoom-level-2.5.0.tgz#286ec16f247b8bb7a900df6612567688eeef498a" From b80986db0050e30fbff1ca4ba8e06d4c57922322 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 15 Jul 2026 15:31:43 +0200 Subject: [PATCH 02/14] fix(metadata-sidebar): fix mocks --- src/api/__tests__/Metadata.test.js | 2 ++ .../__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx | 1 + .../content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx | 3 +++ 3 files changed, 6 insertions(+) diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 78e1608895..6c76402fa2 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -13,6 +13,7 @@ import { ERROR_CODE_FETCH_SKILLS, ERROR_CODE_UPDATE_METADATA, ERROR_CODE_UPDATE_SKILLS, + METADATA_NAMESPACE_GLOBAL, METADATA_SCOPE_GLOBAL, METADATA_SUGGESTIONS_CONFIDENCE_EXPERIMENTAL, METADATA_TEMPLATE_CLASSIFICATION, @@ -162,6 +163,7 @@ describe('api/Metadata', () => { expect(metadata.getCustomPropertiesTemplate()).toEqual({ id: expect.stringContaining('metadata_template_'), scope: METADATA_SCOPE_GLOBAL, + namespace: METADATA_NAMESPACE_GLOBAL, templateKey: METADATA_TEMPLATE_PROPERTIES, hidden: false, fields: [], diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx index 90ae169c38..7002a171c4 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx @@ -107,6 +107,7 @@ describe('MetadataSidebarRedesign taxonomy picker wiring', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [taxonomyTemplateInstance], templates: mockTemplates, errorMessage: null, diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 7fa8b96452..d23d0343bb 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -205,6 +205,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, @@ -225,6 +226,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, @@ -245,6 +247,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, From 44b6a76b9a7b8e3ed87c873d928e56960992e693 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Thu, 16 Jul 2026 14:32:26 +0200 Subject: [PATCH 03/14] fix(metadata-sidebar): Add dependency --- package.json | 4 +++- yarn.lock | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a75c6c6005..f4462549e8 100644 --- a/package.json +++ b/package.json @@ -142,10 +142,11 @@ "@box/frontend": "^11.0.1", "@box/item-icon": "^3.2.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^2.2.28", + "@box/metadata-editor": "^2.3.0", "@box/metadata-filter": "^1.80.23", "@box/metadata-taxonomy-picker": "^3.1.25", "@box/metadata-template-browser": "^2.1.7", + "@box/metadata-template-editor": "^2.2.16", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.8", @@ -320,6 +321,7 @@ "@box/metadata-filter": "^1.80.23", "@box/metadata-taxonomy-picker": "^3.1.8", "@box/metadata-template-browser": "^2.1.7", + "@box/metadata-template-editor": "^2.3.0", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.8", diff --git a/yarn.lock b/yarn.lock index dfe85805f5..bb8edf8dc9 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.2.28": - version "2.2.28" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.2.28.tgz#4e0f12a0d2c0797855f0beb87fafdea8de5fc44b" - integrity sha512-xa49ovqe7ePMnZh+cq1B6kkCQCq55TQO5+BaB/pits+EPOFnZ5CawZYzzwb8PXIp/kWp2f2yw0D9kSqYnkv6NQ== +"@box/metadata-editor@^2.3.0": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.3.1.tgz#fe71042371e6bf2218a57ed3bd09ff4eff67fa36" + integrity sha512-zozo/NzQMosmam9nfEITeMOA0YakZocc3Rgy/5K0FcZvUs78OTwVK3zPTNA3DdL6czK7kLDYdoBggftHfu3Kbg== "@box/metadata-filter@^1.80.23": version "1.80.23" @@ -1197,6 +1197,11 @@ resolved "https://registry.yarnpkg.com/@box/metadata-template-browser/-/metadata-template-browser-2.1.7.tgz#f31de745e40004805a230cd624912e1c5cf9547f" integrity sha512-MzuFyzVInAR0ZqT4g3wro47DmirT39YaYIsxRborZtL1TpaQpgxZoqCWQ+/pgtuGoGHyoP+kpueO8APeC+XCGg== +"@box/metadata-template-editor@^2.2.16": + version "2.2.16" + resolved "https://registry.yarnpkg.com/@box/metadata-template-editor/-/metadata-template-editor-2.2.16.tgz#3eb232280235023cde5e885d876f990365e399c7" + integrity sha512-L2Qt1jLzMlQEQ1dAZHPsznDTWjeHvM5O0XfHtaXlah3F5FvVP7gdCsiAkEGc4dJaJw3f9pyV2HeLCZnqQMed0g== + "@box/metadata-view@^1.53.26": version "1.53.26" resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-1.53.26.tgz#fdb3ebb1e079ba899c491c5bc9971bc4402f63e2" From 1ce2b452c85ffe7a20fb54492ac0841de913e716 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Thu, 16 Jul 2026 14:57:49 +0200 Subject: [PATCH 04/14] fix(metadata-sidebar): Add to ignore patterns --- scripts/jest/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index 515cf24e99..a7d13858f1 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -28,6 +28,6 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ - 'node_modules/(?!(@box/activity-feed|@box/blueprint-web|@box/blueprint-web-assets|@box/box-ai-agent-selector|@box/box-ai-content-answers|@box/box-item-type-selector|@box/cldr-data|@box/collaboration-popover|@box/combobox-with-api|@box/content-field|@box/copy-input|@box/item-icon|@box/metadata-editor|@box/metadata-filter|@box/metadata-taxonomy-picker|@box/metadata-template-browser|@box/metadata-view|@box/react-virtualized/dist/es|@box/readable-time|@box/threaded-annotations|@box/tree|@box/types|@box/unified-share-modal|@box/uploads-manager|@box/user-selector)/)', + 'node_modules/(?!(@box/activity-feed|@box/blueprint-web|@box/blueprint-web-assets|@box/box-ai-agent-selector|@box/box-ai-content-answers|@box/box-item-type-selector|@box/cldr-data|@box/collaboration-popover|@box/combobox-with-api|@box/content-field|@box/copy-input|@box/item-icon|@box/metadata-editor|@box/metadata-filter|@box/metadata-taxonomy-picker|@box/metadata-template-browser|@box/metadata-template-editor|@box/metadata-view|@box/react-virtualized/dist/es|@box/readable-time|@box/threaded-annotations|@box/tree|@box/types|@box/unified-share-modal|@box/uploads-manager|@box/user-selector)/)', ], }; From a9dca29dda29ff57bfb5ad21633a8926ad1f3553 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Fri, 17 Jul 2026 14:51:34 +0200 Subject: [PATCH 05/14] feat(metadata-sidebar): Add enterprise id hook ans tests --- .../MetadataSidebarRedesign.tsx | 10 +- .../MetadataSidebarRedesign.test.tsx | 22 +++ .../useCurrentUserEnterpriseId.test.ts | 143 +++++++++++++++ .../useMetadataNamespaceMode.test.ts | 111 ++++++++++++ .../useMetadataTemplateEditor.test.tsx | 135 ++++++++++++++ .../useMetadataTemplateEventService.test.ts | 116 ++++++++++++ .../useMetadataTemplateItemsService.test.tsx | 168 ++++++++++++++++++ .../hooks/useCurrentUserEnterpriseId.ts | 64 +++++++ .../hooks/useMetadataNamespaceMode.ts | 6 +- .../hooks/useMetadataTemplateItemsService.ts | 2 +- 10 files changed, 767 insertions(+), 10 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/useCurrentUserEnterpriseId.test.ts create mode 100644 src/elements/content-sidebar/__tests__/useMetadataNamespaceMode.test.ts create mode 100644 src/elements/content-sidebar/__tests__/useMetadataTemplateEditor.test.tsx create mode 100644 src/elements/content-sidebar/__tests__/useMetadataTemplateEventService.test.ts create mode 100644 src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx create mode 100644 src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 5add1912a9..9f270ba9ea 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -35,7 +35,6 @@ import { ORIGIN_METADATA_SIDEBAR_REDESIGN, SIDEBAR_VIEW_METADATA, ERROR_CODE_METADATA_STRUCTURED_TEXT_REP, - METADATA_SCOPE_ENTERPRISE, METADATA_SCOPE_MODE_SCOPED, } from '../../constants'; import { EVENT_JS_READY } from '../common/logger/constants'; @@ -62,6 +61,7 @@ import useMetadataSidebarUnsavedChangesGuard from './hooks/useMetadataSidebarUns import useMetadataTemplateEditor from './hooks/useMetadataTemplateEditor'; import useMetadataTemplateItemsService from './hooks/useMetadataTemplateItemsService'; import useMetadataNamespaceMode from './hooks/useMetadataNamespaceMode'; +import useCurrentUserEnterpriseId from './hooks/useCurrentUserEnterpriseId'; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}_${EVENT_JS_READY}`; @@ -179,15 +179,13 @@ function MetadataSidebarRedesign({ // Template management — gated behind namespace migration mode (MIGRATION or FINAL). const [isDropdownOpen, setIsDropdownOpen] = useState(undefined); - // Real enterprise FQN (e.g. "enterprise_123456") derived from the already-loaded templates. - // The numeric ID (without the "enterprise_" prefix) is used for the enterprise configurations API. - const enterpriseId = templates.find(t => t.scope?.startsWith(`${METADATA_SCOPE_ENTERPRISE}_`))?.scope; - const enterpriseNumericId = enterpriseId?.slice(METADATA_SCOPE_ENTERPRISE.length + 1); - // Fetch the migration mode from the enterprise configurations API. // Gated behind the enterprise_metadata_namespaces_opt_in split treatment — when the // flag is off the hook skips the API call and returns null (= legacy SCOPED behaviour). + // Enterprise ID comes from the current user (not templates), so mode can be resolved + // even when the file has no enterprise templates yet. const isNamespacesOptInEnabled: boolean = useFeatureEnabled('metadata.namespacesOptIn.enabled'); + const { enterpriseId, enterpriseNumericId } = useCurrentUserEnterpriseId(api, file, isNamespacesOptInEnabled); const { mode: metadataNamespaceMode } = useMetadataNamespaceMode( file, api, diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index d23d0343bb..169cf1e48f 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -198,6 +198,28 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect(screen.getByRole('button', { name: 'Add template' })).toBeInTheDocument(); }); + // Regression: templates is null while the fetcher is loading. Reading templates.find + // without optional chaining crashed the sidebar before the Add button could mount. + test('should not crash while metadata templates are still loading', () => { + mockUseSidebarMetadataFetcher.mockReturnValue({ + clearExtractError: jest.fn(), + extractSuggestions: jest.fn(), + handleCreateMetadataInstance: jest.fn(), + handleDeleteMetadataInstance: jest.fn(), + handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), + templateInstances: [], + templates: null, + errorMessage: null, + status: STATUS.LOADING, + file: null, + extractErrorCode: null, + }); + + expect(() => renderComponent()).not.toThrow(); + expect(screen.queryByRole('button', { name: 'Add template' })).not.toBeInTheDocument(); + }); + test('should render "Add template" button when user has can_upload permission', () => { mockUseSidebarMetadataFetcher.mockReturnValue({ clearExtractError: jest.fn(), diff --git a/src/elements/content-sidebar/__tests__/useCurrentUserEnterpriseId.test.ts b/src/elements/content-sidebar/__tests__/useCurrentUserEnterpriseId.test.ts new file mode 100644 index 0000000000..90677f6674 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useCurrentUserEnterpriseId.test.ts @@ -0,0 +1,143 @@ +import { FIELD_ENTERPRISE, METADATA_SCOPE_ENTERPRISE } from '../../../constants'; +import { renderHook, waitFor } from '../../../test-utils/testing-library'; +import useCurrentUserEnterpriseId from '../hooks/useCurrentUserEnterpriseId'; + +describe('useCurrentUserEnterpriseId', () => { + const mockFile = { id: 'file-123' }; + const enterpriseNumericId = '173733877'; + + let getUser: jest.Mock; + let api: { getUsersAPI: jest.Mock }; + + beforeEach(() => { + getUser = jest.fn(); + api = { + getUsersAPI: jest.fn().mockReturnValue({ getUser }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return undefined ids before the user request resolves', () => { + const { result } = renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, true)); + + expect(result.current).toEqual({ + enterpriseId: undefined, + enterpriseNumericId: undefined, + }); + }); + + test('should skip the user fetch when isEnabled is false', () => { + renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, false)); + + expect(api.getUsersAPI).not.toHaveBeenCalled(); + expect(getUser).not.toHaveBeenCalled(); + }); + + test('should skip the user fetch when file is null', () => { + renderHook(() => useCurrentUserEnterpriseId(api as never, null, true)); + + expect(api.getUsersAPI).not.toHaveBeenCalled(); + expect(getUser).not.toHaveBeenCalled(); + }); + + test('should fetch the current user with the enterprise field and return FQN ids', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ enterprise: { id: enterpriseNumericId } }); + }); + + const { result } = renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, true)); + + expect(api.getUsersAPI).toHaveBeenCalledWith(false); + expect(getUser).toHaveBeenCalledWith(mockFile.id, expect.any(Function), expect.any(Function), { + params: { + fields: FIELD_ENTERPRISE, + }, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + enterpriseId: `${METADATA_SCOPE_ENTERPRISE}_${enterpriseNumericId}`, + enterpriseNumericId, + }); + }); + }); + + test('should return undefined ids when the user has no enterprise', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ id: 'user-1' }); + }); + + const { result } = renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, true)); + + await waitFor(() => { + expect(getUser).toHaveBeenCalled(); + }); + + expect(result.current).toEqual({ + enterpriseId: undefined, + enterpriseNumericId: undefined, + }); + }); + + test('should return undefined ids when the user request fails', async () => { + getUser.mockImplementation((_id, _successCallback, errorCallback) => { + errorCallback(new Error('failed')); + }); + + const { result } = renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, true)); + + await waitFor(() => { + expect(getUser).toHaveBeenCalled(); + }); + + expect(result.current).toEqual({ + enterpriseId: undefined, + enterpriseNumericId: undefined, + }); + }); + + test('should clear ids when isEnabled becomes false after a successful fetch', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ enterprise: { id: enterpriseNumericId } }); + }); + + const { result, rerender } = renderHook( + ({ isEnabled }) => useCurrentUserEnterpriseId(api as never, mockFile, isEnabled), + { initialProps: { isEnabled: true } }, + ); + + await waitFor(() => { + expect(result.current.enterpriseNumericId).toBe(enterpriseNumericId); + }); + + rerender({ isEnabled: false }); + + await waitFor(() => { + expect(result.current).toEqual({ + enterpriseId: undefined, + enterpriseNumericId: undefined, + }); + }); + expect(getUser).toHaveBeenCalledTimes(1); + }); + + test('should ignore a stale success callback after unmount', async () => { + let successCallback: ((user: { enterprise?: { id: string } }) => void) | undefined; + getUser.mockImplementation((_id, onSuccess) => { + successCallback = onSuccess; + }); + + const { result, unmount } = renderHook(() => useCurrentUserEnterpriseId(api as never, mockFile, true)); + + unmount(); + successCallback?.({ enterprise: { id: enterpriseNumericId } }); + + expect(result.current).toEqual({ + enterpriseId: undefined, + enterpriseNumericId: undefined, + }); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useMetadataNamespaceMode.test.ts b/src/elements/content-sidebar/__tests__/useMetadataNamespaceMode.test.ts new file mode 100644 index 0000000000..5ec281e841 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useMetadataNamespaceMode.test.ts @@ -0,0 +1,111 @@ +import { renderHook, waitFor } from '../../../test-utils/testing-library'; +import useMetadataNamespaceMode from '../hooks/useMetadataNamespaceMode'; + +describe('useMetadataNamespaceMode', () => { + const mockFile = { id: 'file-123' }; + const enterpriseNumericId = '173733877'; + + let getMetadataNamespaceMode: jest.Mock; + let api: { getMetadataAPI: jest.Mock }; + + beforeEach(() => { + getMetadataNamespaceMode = jest.fn().mockResolvedValue('SCOPED'); + api = { + getMetadataAPI: jest.fn().mockReturnValue({ getMetadataNamespaceMode }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should skip the fetch and return null mode when isEnabled is false', () => { + const { result } = renderHook(() => + useMetadataNamespaceMode(mockFile as never, api as never, enterpriseNumericId, false), + ); + + expect(api.getMetadataAPI).not.toHaveBeenCalled(); + expect(result.current).toEqual({ mode: null, isLoading: false }); + }); + + test('should skip the fetch when enterpriseNumericId is undefined', () => { + const { result } = renderHook(() => useMetadataNamespaceMode(mockFile as never, api as never, undefined, true)); + + expect(api.getMetadataAPI).not.toHaveBeenCalled(); + expect(result.current).toEqual({ mode: null, isLoading: false }); + }); + + test('should skip the fetch when file is missing', () => { + const { result } = renderHook(() => + useMetadataNamespaceMode(null as never, api as never, enterpriseNumericId, true), + ); + + expect(api.getMetadataAPI).not.toHaveBeenCalled(); + expect(result.current).toEqual({ mode: null, isLoading: false }); + }); + + test('should fetch namespace mode and expose loading then success state', async () => { + let resolveMode: (mode: string) => void = () => undefined; + getMetadataNamespaceMode.mockReturnValue( + new Promise(resolve => { + resolveMode = resolve; + }), + ); + + const { result } = renderHook(() => + useMetadataNamespaceMode(mockFile as never, api as never, enterpriseNumericId, true), + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.mode).toBeNull(); + expect(api.getMetadataAPI).toHaveBeenCalledWith(false); + expect(getMetadataNamespaceMode).toHaveBeenCalledWith(mockFile, enterpriseNumericId); + + resolveMode('MIGRATION'); + + await waitFor(() => { + expect(result.current).toEqual({ mode: 'MIGRATION', isLoading: false }); + }); + }); + + test('should clear mode when isEnabled becomes false', async () => { + getMetadataNamespaceMode.mockResolvedValue('FINAL'); + + const { result, rerender } = renderHook( + ({ isEnabled }) => + useMetadataNamespaceMode(mockFile as never, api as never, enterpriseNumericId, isEnabled), + { initialProps: { isEnabled: true } }, + ); + + await waitFor(() => { + expect(result.current.mode).toBe('FINAL'); + }); + + rerender({ isEnabled: false }); + + await waitFor(() => { + expect(result.current).toEqual({ mode: null, isLoading: false }); + }); + }); + + test('should ignore a stale response after unmount', async () => { + let resolveMode: (mode: string) => void = () => undefined; + getMetadataNamespaceMode.mockReturnValue( + new Promise(resolve => { + resolveMode = resolve; + }), + ); + + const { result, unmount } = renderHook(() => + useMetadataNamespaceMode(mockFile as never, api as never, enterpriseNumericId, true), + ); + + unmount(); + resolveMode('MIGRATION'); + + // Allow any lingering microtasks to flush without updating unmounted state. + await Promise.resolve(); + + expect(result.current).toEqual({ mode: null, isLoading: true }); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateEditor.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateEditor.test.tsx new file mode 100644 index 0000000000..2f1ae0ad07 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateEditor.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { MetadataTemplateEditorMode } from '@box/metadata-template-editor'; +import { act, render, renderHook, screen } from '../../../test-utils/testing-library'; +import useMetadataTemplateEditor from '../hooks/useMetadataTemplateEditor'; + +jest.mock('@box/metadata-template-editor', () => { + const actual = jest.requireActual('@box/metadata-template-editor'); + return { + ...actual, + MetadataTemplateEditorModal: ({ + mode, + namespace, + onCreateTemplate, + onEditTemplate, + onOpenChange, + fetchTemplate, + }: { + mode: string; + namespace?: string; + onCreateTemplate?: (body: unknown) => Promise; + onEditTemplate?: (patch: unknown[], id: unknown) => Promise; + onOpenChange: (open: boolean) => void; + fetchTemplate?: () => Promise; + }) => ( +
+ {mode} + {namespace ? {namespace} : null} + {fetchTemplate ? true : null} + + + +
+ ), + }; +}); + +describe('useMetadataTemplateEditor', () => { + const onCreate = jest.fn().mockResolvedValue(undefined); + const onEdit = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return null modal while closed', () => { + const { result } = renderHook(() => useMetadataTemplateEditor({ onCreate, onEdit })); + + expect(result.current.modal).toBeNull(); + }); + + test('should open create mode and close after successful create', async () => { + const { result } = renderHook(() => useMetadataTemplateEditor({ onCreate, onEdit })); + + act(() => { + result.current.openCreate('enterprise_123'); + }); + + const { rerender } = render(<>{result.current.modal}); + expect(screen.getByTestId('editor-mode')).toHaveTextContent(MetadataTemplateEditorMode.Create); + expect(screen.getByTestId('editor-namespace')).toHaveTextContent('enterprise_123'); + + await act(async () => { + screen.getByRole('button', { name: 'submit-create' }).click(); + }); + + expect(onCreate).toHaveBeenCalledWith({ templateKey: 'new', namespace: 'enterprise_123' }); + + rerender(<>{result.current.modal}); + expect(screen.queryByTestId('template-editor-modal')).not.toBeInTheDocument(); + }); + + test('should open edit mode and close after successful edit', async () => { + const fetchTemplate = jest.fn().mockResolvedValue({ templateKey: 'myTemplate' }); + const { result } = renderHook(() => useMetadataTemplateEditor({ onCreate, onEdit })); + + act(() => { + result.current.openEdit({ + namespaceFqn: 'enterprise_123', + templateKey: 'myTemplate', + fetchTemplate, + }); + }); + + const { rerender } = render(<>{result.current.modal}); + expect(screen.getByTestId('editor-mode')).toHaveTextContent(MetadataTemplateEditorMode.Edit); + expect(screen.getByTestId('editor-has-fetch')).toHaveTextContent('true'); + + await act(async () => { + screen.getByRole('button', { name: 'submit-edit' }).click(); + }); + + expect(onEdit).toHaveBeenCalledWith([{ op: 'replace', path: '/displayName', value: 'Updated' }], { + namespaceFQN: 'enterprise_1', + templateKey: 'myTemplate', + }); + + rerender(<>{result.current.modal}); + expect(screen.queryByTestId('template-editor-modal')).not.toBeInTheDocument(); + }); + + test('should close the modal when onOpenChange(false) is called', () => { + const { result } = renderHook(() => useMetadataTemplateEditor({ onCreate, onEdit })); + + act(() => { + result.current.openCreate('enterprise_123'); + }); + + const { rerender } = render(<>{result.current.modal}); + expect(screen.getByTestId('template-editor-modal')).toBeInTheDocument(); + + act(() => { + screen.getByRole('button', { name: 'close' }).click(); + }); + + rerender(<>{result.current.modal}); + expect(screen.queryByTestId('template-editor-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateEventService.test.ts b/src/elements/content-sidebar/__tests__/useMetadataTemplateEventService.test.ts new file mode 100644 index 0000000000..aa1be776ad --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateEventService.test.ts @@ -0,0 +1,116 @@ +import { act, renderHook } from '../../../test-utils/testing-library'; +import useMetadataTemplateEventService from '../hooks/useMetadataTemplateEventService'; + +describe('useMetadataTemplateEventService', () => { + const editorTemplates = [ + { + id: 'tmpl-1', + templateKey: 'myTemplate', + scope: 'enterprise_123', + type: 'metadata_template', + displayName: 'My Template', + canEdit: true, + hidden: false, + fields: [], + }, + { + id: 'tmpl-2', + templateKey: 'otherTemplate', + scope: 'enterprise_123', + type: 'metadata_template', + displayName: 'Other Template', + canEdit: true, + hidden: false, + fields: [], + }, + ]; + + const onSelect = jest.fn(); + const onCreateTemplate = jest.fn(); + const onEditTemplate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should resolve selection by exact template id', async () => { + const { result } = renderHook(() => + useMetadataTemplateEventService({ + templates: editorTemplates as never, + onSelect, + }), + ); + + await act(async () => { + await result.current.onTemplateSelect({ + id: 'tmpl-1', + templateKey: 'unrelated', + scope: 'enterprise_999', + } as never); + }); + + expect(onSelect).toHaveBeenCalledWith(editorTemplates[0]); + }); + + test('should fall back to templateKey + scope when ids do not match', async () => { + const { result } = renderHook(() => + useMetadataTemplateEventService({ + templates: editorTemplates as never, + onSelect, + }), + ); + + await act(async () => { + await result.current.onTemplateSelect({ + id: 'browser-only-id', + templateKey: 'otherTemplate', + scope: 'enterprise_123', + } as never); + }); + + expect(onSelect).toHaveBeenCalledWith(editorTemplates[1]); + }); + + test('should not call onSelect when no editor template matches', async () => { + const { result } = renderHook(() => + useMetadataTemplateEventService({ + templates: editorTemplates as never, + onSelect, + }), + ); + + await act(async () => { + await result.current.onTemplateSelect({ + id: 'missing', + templateKey: 'missing', + scope: 'enterprise_123', + } as never); + }); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('should include create/edit handlers only when provided', () => { + const { result: withoutOptional } = renderHook(() => + useMetadataTemplateEventService({ + templates: editorTemplates as never, + onSelect, + }), + ); + + expect(withoutOptional.current.onCreateTemplate).toBeUndefined(); + expect((withoutOptional.current as { onTemplateEdit?: unknown }).onTemplateEdit).toBeUndefined(); + + const { result: withOptional } = renderHook(() => + useMetadataTemplateEventService({ + templates: editorTemplates as never, + onSelect, + onCreateTemplate, + onEditTemplate, + }), + ); + + expect(withOptional.current.onCreateTemplate).toBe(onCreateTemplate); + expect((withOptional.current as { onTemplateEdit: typeof onEditTemplate }).onTemplateEdit).toBe(onEditTemplate); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx new file mode 100644 index 0000000000..26f5c821c6 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useMetadataTemplateItemsService.test.tsx @@ -0,0 +1,168 @@ +/// +import { renderHook } from '@testing-library/react'; +import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; +import useMetadataTemplateItemsService from '../hooks/useMetadataTemplateItemsService'; + +describe('useMetadataTemplateItemsService', () => { + const mockFile = { id: 'file-123' }; + const enterpriseFqn = 'enterprise_173733877'; + + const templates = [ + { + id: 'editor-1', + templateKey: 'myTemplate', + scope: enterpriseFqn, + type: 'metadata_template', + displayName: 'My Template', + canEdit: true, + hidden: false, + fields: [], + }, + { + id: 'editor-props', + templateKey: METADATA_TEMPLATE_PROPERTIES, + scope: 'global', + type: 'metadata_template', + displayName: 'Properties', + canEdit: true, + hidden: false, + fields: [], + }, + ]; + + let listNamespaces: jest.Mock; + let listTemplatesForNamespace: jest.Mock; + let api: { getMetadataAPI: jest.Mock }; + + beforeEach(() => { + listNamespaces = jest.fn().mockResolvedValue({ entries: [], next_marker: undefined }); + listTemplatesForNamespace = jest.fn().mockResolvedValue({ entries: [], next_marker: undefined }); + api = { + getMetadataAPI: jest.fn().mockReturnValue({ + listNamespaces, + listTemplatesForNamespace, + }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return undefined when enterpriseFqn is not known', () => { + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, undefined, templates as never), + ); + + expect(result.current).toBeUndefined(); + }); + + test('should delegate getNamespaces to the metadata API', async () => { + const namespacesResponse = { + entries: [{ id: 'ns-1', fqn: `${enterpriseFqn}.child`, displayName: 'Child' }], + next_marker: 'marker-1', + }; + listNamespaces.mockResolvedValue(namespacesResponse); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + await expect(result.current!.getNamespaces(enterpriseFqn, { limit: 20, marker: 'm0' })).resolves.toEqual( + namespacesResponse, + ); + expect(api.getMetadataAPI).toHaveBeenCalledWith(false); + expect(listNamespaces).toHaveBeenCalledWith(mockFile, enterpriseFqn, { limit: 20, marker: 'm0' }); + }); + + test('should map getTemplates entries and prefer editor template ids', async () => { + listTemplatesForNamespace.mockResolvedValue({ + entries: [ + { + id: 'api-id-1', + templateKey: 'myTemplate', + namespace: enterpriseFqn, + displayName: 'My Template', + canEdit: true, + hidden: false, + }, + { + id: 'api-id-2', + templateKey: 'childOnly', + namespace: `${enterpriseFqn}.child`, + displayName: 'Child Only', + }, + ], + next_marker: undefined, + }); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, templates as never), + ); + + await expect(result.current!.getTemplates(enterpriseFqn, { limit: 50, marker: undefined })).resolves.toEqual({ + entries: [ + { + id: 'editor-1', + type: 'metadata_template', + displayName: 'My Template', + scope: enterpriseFqn, + templateKey: 'myTemplate', + canEdit: true, + hidden: false, + }, + { + id: 'api-id-2', + type: 'metadata_template', + displayName: 'Child Only', + scope: `${enterpriseFqn}.child`, + templateKey: 'childOnly', + canEdit: false, + hidden: false, + }, + ], + next_marker: undefined, + }); + }); + + 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: [], + })); + + const { result } = renderHook(() => + useMetadataTemplateItemsService(api as never, mockFile as never, enterpriseFqn, manyTemplates as never), + ); + + await expect(result.current!.getSearchResults('alpha', { limit: 2, marker: undefined })).resolves.toEqual({ + entries: [ + expect.objectContaining({ displayName: 'Alpha 0' }), + expect.objectContaining({ displayName: 'Alpha 2' }), + ], + next_marker: '2', + }); + + await expect(result.current!.getSearchResults('alpha', { limit: 2, marker: '2' })).resolves.toEqual({ + entries: [expect.objectContaining({ displayName: 'Alpha 4' })], + next_marker: undefined, + }); + }); + + test('should use the localized custom metadata name for properties templates in search', async () => { + 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' })], + next_marker: undefined, + }); + }); +}); diff --git a/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts b/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts new file mode 100644 index 0000000000..7400c8f6e7 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import API from '../../../api'; +import { FIELD_ENTERPRISE, METADATA_SCOPE_ENTERPRISE } from '../../../constants'; +import type { BoxItem, User } from '../../../common/types/core'; + +interface UseCurrentUserEnterpriseIdReturn { + /** Full enterprise FQN (e.g. `"enterprise_123456"`), or `undefined` while loading / unavailable. */ + enterpriseId: string | undefined; + /** Numeric enterprise ID (without the `"enterprise_"` prefix), or `undefined` while loading / unavailable. */ + enterpriseNumericId: string | undefined; +} + +/** + * Resolves the authenticated user's enterprise ID via `GET /users/me?fields=enterprise`. + * + * Independent of metadata templates/instances so callers can hit + * `enterprise_configurations` (and build an `enterprise_` FQN) even when the + * file has no templates applied yet. + * + * When `isEnabled` is `false` the fetch is skipped. + */ +export default function useCurrentUserEnterpriseId( + api: API, + file: BoxItem | null, + isEnabled: boolean = true, +): UseCurrentUserEnterpriseIdReturn { + const [enterpriseNumericId, setEnterpriseNumericId] = useState(undefined); + + useEffect(() => { + if (!isEnabled || !file?.id) { + setEnterpriseNumericId(undefined); + return undefined; + } + + let cancelled = false; + + api.getUsersAPI(false).getUser( + file.id, + (user: User) => { + if (!cancelled) { + setEnterpriseNumericId(user?.enterprise?.id); + } + }, + () => { + if (!cancelled) { + setEnterpriseNumericId(undefined); + } + }, + { + params: { + fields: FIELD_ENTERPRISE, + }, + }, + ); + + return () => { + cancelled = true; + }; + }, [api, file?.id, isEnabled]); + + const enterpriseId = enterpriseNumericId ? `${METADATA_SCOPE_ENTERPRISE}_${enterpriseNumericId}` : undefined; + + return { enterpriseId, enterpriseNumericId }; +} diff --git a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts index d674010896..441664bfc8 100644 --- a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts @@ -19,9 +19,9 @@ interface UseMetadataNamespaceModeReturn { * Fetches the metadata namespace migration mode for the given enterprise from * `GET /2.0/enterprise_configurations/{enterpriseNumericId}`. * - * The fetch is deferred until `enterpriseNumericId` is known (i.e. after - * templates are loaded). Cancels any in-flight request when the component - * unmounts or the enterprise ID changes. + * The fetch is deferred until `enterpriseNumericId` is known (typically from + * the current user's enterprise). Cancels any in-flight request when the + * component unmounts or the enterprise ID changes. * * When `isEnabled` is `false` the hook skips the API call entirely and returns * `{ mode: null, isLoading: false }`, keeping the UI in legacy SCOPED mode. diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 45b1e70281..3b7704b59a 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -31,7 +31,7 @@ function resolveDisplayName(template: EditorMetadataTemplate, customMetadataName * already fetched by `useSidebarMetadataFetcher`. A server-side search endpoint * would replace this body when available. * - * Returns `undefined` when `enterpriseFqn` is not yet known (templates still loading). + * Returns `undefined` when `enterpriseFqn` is not yet known (current user still loading). * * @example * const itemsService = useMetadataTemplateItemsService(api, enterpriseFqn, templates); From 359b4366cca76f6ecd15bbf5a8bc93586d822198 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Mon, 20 Jul 2026 11:07:50 +0200 Subject: [PATCH 06/14] fix(metadata-sidebar): Fix flow issues --- src/api/Metadata.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 775032428b..ee3ff5ed92 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -726,7 +726,7 @@ class Metadata extends File { const url = this.getMetadataTemplateSchemaUrl(templateKey, scope); const response = await this.xhr.get({ url, - ...(fileId && { id: getTypedFileId(fileId) }), + ...(fileId ? { id: getTypedFileId(fileId) } : {}), }); cache.set(key, response); @@ -872,9 +872,9 @@ class Metadata extends File { template = templates.find(t => t.templateKey === templateKey && t.namespace === namespace); } - // Enterprise scopes are always enterprise_XXXXX; use optional chaining - // to guard against namespace-only instances where $scope is undefined. - if (!template && scope?.startsWith(METADATA_SCOPE_ENTERPRISE)) { + // Enterprise scopes are always enterprise_XXXXX; guard against + // namespace-only instances where $scope is undefined. + if (!template && scope && scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { // Any missing template is likely from another enterprise (e.g. collaborated file); // Templates array has no pagination so we can assume cross-enterprise as it contains all templates. const crossEnterpriseTemplates = await this.getTemplates(id, scope, instanceId, true); @@ -1062,12 +1062,13 @@ class Metadata extends File { */ getEnterpriseScopeFromInstances(instances: Array): string | null { for (const inst of instances) { - if (inst.$scope?.startsWith(METADATA_SCOPE_ENTERPRISE)) { - return inst.$scope; + const { $scope, $namespace } = inst; + if ($scope && $scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { + return $scope; } - if (inst.$namespace?.startsWith(METADATA_SCOPE_ENTERPRISE)) { + if ($namespace && $namespace.startsWith(METADATA_SCOPE_ENTERPRISE)) { // Namespace FQNs may be "enterprise_123/key" — take the leading segment. - return inst.$namespace.split('/')[0]; + return $namespace.split('/')[0]; } } return null; From bd6990856c6d2c6166e9cfe2414102935a4bde94 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Fri, 24 Jul 2026 16:07:43 +0200 Subject: [PATCH 07/14] fix(metadata-sidebar): Address improvements --- scripts/i18n.config.js | 1 + src/api/Metadata.js | 12 ++++++--- src/api/__tests__/Metadata.test.js | 42 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/scripts/i18n.config.js b/scripts/i18n.config.js index 9904ca2522..f41446bede 100644 --- a/scripts/i18n.config.js +++ b/scripts/i18n.config.js @@ -14,6 +14,7 @@ module.exports = { '@box/metadata-filter', '@box/metadata-taxonomy-picker', '@box/metadata-template-browser', + '@box/metadata-template-editor', '@box/metadata-view', '@box/readable-time', '@box/threaded-annotations', diff --git a/src/api/Metadata.js b/src/api/Metadata.js index ee3ff5ed92..c3ca9b94b0 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -121,10 +121,11 @@ class Metadata extends File { * Creates a key for the metadata template schema cache * * @param {string} templateKey - template key + * @param {string} [scope] - scope or namespace FQN (defaults to `enterprise`) * @return {string} key */ - getMetadataTemplateSchemaCacheKey(templateKey: string): string { - return `${CACHE_PREFIX_METADATA}template_schema_${templateKey}`; + getMetadataTemplateSchemaCacheKey(templateKey: string, scope?: string = METADATA_SCOPE_ENTERPRISE): string { + return `${CACHE_PREFIX_METADATA}template_schema_${scope}_${templateKey}`; } /** @@ -717,7 +718,7 @@ class Metadata extends File { fileId?: string, ): Promise { const cache: APICache = this.getCache(); - const key = this.getMetadataTemplateSchemaCacheKey(templateKey); + const key = this.getMetadataTemplateSchemaCacheKey(templateKey, scope); if (cache.has(key)) { return cache.get(key); @@ -864,7 +865,10 @@ class Metadata extends File { // Primary match: by scope (SCOPED mode; also works for enterprise-scoped // instances in MIGRATION mode where $scope is still populated). - let template = templates.find(t => t.templateKey === templateKey && t.scope === scope); + // 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. diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 6c76402fa2..066c632fc5 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -771,6 +771,25 @@ describe('api/Metadata', () => { expect(metadata.xhr.get).toHaveBeenCalledWith({ url }); expect(response).toBe(metadataTemplate); }); + + test('should use distinct cache keys when scope differs', async () => { + const templateKey = 'templateKey_123'; + const enterpriseResponse = { data: { scope: 'enterprise' } }; + const namespaceResponse = { data: { namespace: 'enterprise_123' } }; + metadata.xhr.get = jest + .fn() + .mockResolvedValueOnce(enterpriseResponse) + .mockResolvedValueOnce(namespaceResponse); + + const first = await metadata.getSchemaByTemplateKey(templateKey, 'enterprise'); + const second = await metadata.getSchemaByTemplateKey(templateKey, 'enterprise_123'); + const firstCached = await metadata.getSchemaByTemplateKey(templateKey, 'enterprise'); + + expect(first).toBe(enterpriseResponse); + expect(second).toBe(namespaceResponse); + expect(firstCached).toBe(enterpriseResponse); + expect(metadata.xhr.get).toHaveBeenCalledTimes(2); + }); }); describe('getInstances()', () => { @@ -1025,6 +1044,29 @@ describe('api/Metadata', () => { expect(result.isExternallyOwned).toBe(true); expect(metadata.getTemplates).toBeCalledWith('id', 'enterprise', 'instanceId', true); }); + + test('should match namespace-only instance by namespace when scope is undefined', async () => { + const namespaceTemplate = { + id: 7, + templateKey: 'namespaced1', + namespace: 'enterprise_123.box.extract', + }; + const scopeLessWrongMatch = { + id: 8, + templateKey: 'namespaced1', + }; + const result = await metadata.getTemplateForInstance( + 'id', + { + $id: 'instanceId', + $template: 'namespaced1', + $namespace: 'enterprise_123.box.extract', + }, + [scopeLessWrongMatch, namespaceTemplate], + ); + expect(result.template).toBe(namespaceTemplate); + expect(result.isExternallyOwned).toBe(false); + }); }); describe('getEditors()', () => { From 49ea682d0392a3b72ff11271a877d35eae4c0280 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 29 Jul 2026 14:49:02 +0200 Subject: [PATCH 08/14] feat(metadata-sidebar): Improve mode hook --- .../content-sidebar/hooks/useMetadataNamespaceMode.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts index 441664bfc8..288e8a15e7 100644 --- a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import API from '../../../api'; import type { BoxItem } from '../../../common/types/core'; @@ -44,7 +44,6 @@ export default function useMetadataNamespaceMode( ): UseMetadataNamespaceModeReturn { const [mode, setMode] = useState(null); const [isLoading, setIsLoading] = useState(false); - const cancelledRef = useRef(false); useEffect(() => { if (!isEnabled || !file || !enterpriseNumericId) { @@ -53,19 +52,19 @@ export default function useMetadataNamespaceMode( return undefined; } - cancelledRef.current = false; + let isCancelled = false; setIsLoading(true); const metadataAPI = api.getMetadataAPI(false); metadataAPI.getMetadataNamespaceMode(file, enterpriseNumericId).then(resolvedMode => { - if (!cancelledRef.current) { - setMode(resolvedMode); + if (!isCancelled) { + setMode(resolvedMode as MetadataScopeMode | null); setIsLoading(false); } }); return () => { - cancelledRef.current = true; + isCancelled = true; }; }, [api, file, enterpriseNumericId, isEnabled]); From 67606b8d102f2b14c944f1ddf7a87d04df722319 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Mon, 3 Aug 2026 15:09:49 +0200 Subject: [PATCH 09/14] feat(metadata-sidebar): compose hooks --- src/api/Metadata.js | 431 +++++++----------- src/api/MetadataNamespaces.js | 227 +++++++++ src/api/__tests__/Metadata.test.js | 116 +++++ .../__tests__/metadataNamespaceUtils.test.js | 75 +++ src/api/metadataNamespaceMocks.js | 2 + src/api/metadataNamespaceUtils.js | 87 ++++ .../MetadataSidebarRedesign.tsx | 28 +- ...ataSidebarRedesign-taxonomyPicker.test.tsx | 12 + .../MetadataSidebarRedesign.test.tsx | 17 + .../useMetadataNamespaceContext.test.tsx | 121 +++++ .../hooks/useCurrentUserEnterpriseId.ts | 9 +- .../hooks/useMetadataNamespaceContext.ts | 52 +++ .../hooks/useMetadataNamespaceMode.ts | 9 +- .../hooks/useSidebarMetadataFetcher.ts | 37 +- 14 files changed, 927 insertions(+), 296 deletions(-) create mode 100644 src/api/MetadataNamespaces.js create mode 100644 src/api/__tests__/metadataNamespaceUtils.test.js create mode 100644 src/api/metadataNamespaceUtils.js create mode 100644 src/elements/content-sidebar/__tests__/useMetadataNamespaceContext.test.tsx create mode 100644 src/elements/content-sidebar/hooks/useMetadataNamespaceContext.ts diff --git a/src/api/Metadata.js b/src/api/Metadata.js index c3ca9b94b0..5e594fbd9b 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -30,12 +30,7 @@ import { AI_ACCEPTED_PROCESS, AI_EXTRACTED_PROCESS, HEADER_CONTENT_TYPE, - HEADER_BOX_VERSION, - METADATA_NAMESPACE_MIGRATION_FIELD, - METADATA_NAMESPACE_FINAL_FIELD, METADATA_SCOPE_MODE_SCOPED, - METADATA_SCOPE_MODE_MIGRATION, - METADATA_SCOPE_MODE_FINAL, METADATA_SCOPE_ENTERPRISE, METADATA_SCOPE_GLOBAL, METADATA_NAMESPACE_GLOBAL, @@ -48,9 +43,7 @@ import { CACHE_PREFIX_METADATA, ERROR_CODE_UPDATE_SKILLS, ERROR_CODE_UPDATE_METADATA, - ERROR_CODE_UPDATE_METADATA_TEMPLATE, ERROR_CODE_CREATE_METADATA, - ERROR_CODE_CREATE_METADATA_TEMPLATE, ERROR_CODE_DELETE_METADATA, ERROR_CODE_FETCH_METADATA, ERROR_CODE_FETCH_METADATA_TEMPLATES, @@ -63,7 +56,7 @@ import { ERROR_CODE_FETCH_METADATA_TAXONOMY, } from '../constants'; -import type { RequestOptions, ElementsErrorCallback, JSONPatchOperations } from '../common/types/api'; +import type { ElementsErrorCallback, JSONPatchOperations } from '../common/types/api'; import type { MetadataTemplateSchemaResponse, MetadataTemplate, @@ -76,17 +69,39 @@ import type { } from '../common/types/metadata'; import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; -// TODO(MDX-2136): remove this import when namespace API is deployed +import MetadataNamespaces from './MetadataNamespaces'; import { - IS_NAMESPACE_API_MOCKED, - mockListNamespaces, - mockListTemplatesForNamespace, - mockCreateMetadataTemplate, - mockUpdateMetadataTemplate, - mockGetTemplateSchemaForEditor, -} from './metadataNamespaceMocks'; + getEnterpriseNamespaceFromInstances as resolveEnterpriseNamespaceFromInstances, + getEnterpriseRootFromScopeOrNamespace as resolveEnterpriseRoot, + isTemplateExternallyOwned, + resolveScopeOrNamespace, +} from './metadataNamespaceUtils'; + +/** Options for getMetadata — cache flags plus namespace-migration context. */ +type MetadataGetOptions = { + enterpriseFqn?: string, + fields?: Array, + forceFetch?: boolean, + metadataNamespaceMode?: string, + noPagination?: boolean, + refreshCache?: boolean, +}; class Metadata extends File { + _namespacesAPI: ?MetadataNamespaces; + + /** + * Lazy collaborator for namespace-migration HTTP (list/create/update + mode). + * + * @return {MetadataNamespaces} + */ + getNamespacesAPI(): MetadataNamespaces { + if (!this._namespacesAPI) { + this._namespacesAPI = new MetadataNamespaces(this); + } + return this._namespacesAPI; + } + /** * Creates a key for the metadata cache * @@ -142,13 +157,7 @@ class Metadata extends File { * @return {string} resolved URL path segment */ getScopeOrNamespace(scope: ?string, namespace?: ?string): string { - if (this.metadataNamespaceMode === METADATA_SCOPE_MODE_FINAL && scope === METADATA_SCOPE_GLOBAL) { - return METADATA_NAMESPACE_GLOBAL; - } - if (!scope && namespace) { - return namespace; - } - return scope || ''; + return resolveScopeOrNamespace(this.metadataNamespaceMode, scope, namespace); } /** @@ -281,172 +290,51 @@ class Metadata extends File { return `${this.getMetadataTemplateUrl()}/${scope}`; } - /** - * API URL for listing child namespaces under a given namespace FQN. - * - * @param {string} namespaceFqn - namespace FQN (e.g. "enterprise_123456") - * @return {string} URL for namespace listing - */ + /** @see MetadataNamespaces.getMetadataNamespacesUrl */ getMetadataNamespacesUrl(namespaceFqn: string): string { - return `${this.getBaseApiUrl()}/metadata_namespaces/${namespaceFqn}`; + return this.getNamespacesAPI().getMetadataNamespacesUrl(namespaceFqn); } - /** - * Lists child namespaces under a given namespace FQN. - * Used by the namespace browser's ItemsService.getNamespaces. - * - * @param {BoxItem} file - current file (typed id is used for token auth) - * @param {string} namespaceFqn - parent namespace FQN - * @param {Object} params - pagination params (limit, marker) - * @return {Promise} namespace listing response - */ - async listNamespaces( + /** @see MetadataNamespaces.listNamespaces */ + listNamespaces( file: BoxItem, namespaceFqn: string, params: { limit: number, marker?: string }, ): Promise<{ entries: Array, next_marker?: string }> { - // TODO(MDX-2136): remove next line when namespace API is deployed - if (IS_NAMESPACE_API_MOCKED) return mockListNamespaces(file, namespaceFqn, params); - - const { id }: BoxItem = file; - const url = this.getMetadataNamespacesUrl(namespaceFqn); - try { - const response = await this.xhr.get({ - url, - id: getTypedFileId(id), - params: { limit: params.limit, marker: params.marker }, - }); - return getProp(response, 'data', { entries: [] }); - } catch (e) { - return { entries: [] }; - } + return this.getNamespacesAPI().listNamespaces(file, namespaceFqn, params); } - /** - * Lists templates under a namespace FQN with cursor pagination. - * Used by the namespace browser's ItemsService.getTemplates. - * - * @param {BoxItem} file - current file (typed id is used for token auth) - * @param {string} namespaceFqn - namespace FQN - * @param {Object} params - pagination params (limit, marker) - * @return {Promise} template listing response - */ - async listTemplatesForNamespace( + /** @see MetadataNamespaces.listTemplatesForNamespace */ + listTemplatesForNamespace( file: BoxItem, namespaceFqn: string, params: { limit: number, marker?: string }, ): Promise<{ entries: Array, next_marker?: string }> { - // TODO(MDX-2136): remove next line when namespace API is deployed - if (IS_NAMESPACE_API_MOCKED) return mockListTemplatesForNamespace(file, namespaceFqn, params); - - const { id }: BoxItem = file; - const url = this.getMetadataTemplateUrlForScope(namespaceFqn); - try { - const response = await this.xhr.get({ - url, - id: getTypedFileId(id), - params: { limit: params.limit, marker: params.marker }, - }); - return getProp(response, 'data', { entries: [] }); - } catch (e) { - return { entries: [] }; - } + return this.getNamespacesAPI().listTemplatesForNamespace(file, namespaceFqn, params); } - /** - * Returns the URL for the enterprise configurations endpoint. - * - * @param {string} enterpriseNumericId - numeric enterprise ID (without "enterprise_" prefix) - * @return {string} URL - */ + /** @see MetadataNamespaces.getEnterpriseConfigurationsUrl */ getEnterpriseConfigurationsUrl(enterpriseNumericId: string): string { - return `${this.getBaseApiUrl()}/enterprise_configurations/${enterpriseNumericId}`; + return this.getNamespacesAPI().getEnterpriseConfigurationsUrl(enterpriseNumericId); } - /** - * Fetches the metadata namespace migration mode for the given enterprise from the - * enterprise_configurations endpoint. - * - * The mode is derived from two boolean feature flags in `content_and_sharing`: - * - neither enabled → SCOPED (legacy, pre-migration) - * - migration only → MIGRATION - * - both enabled → FINAL - * - * Returns `null` when the request fails so callers can fall back safely. - * - * @param {string} enterpriseNumericId - numeric enterprise ID (without "enterprise_" prefix) - * @return {Promise} one of the METADATA_SCOPE_MODE_* constants, or null on error - */ - async getMetadataNamespaceMode(file: BoxItem, enterpriseNumericId: string): Promise { - const url = this.getEnterpriseConfigurationsUrl(enterpriseNumericId); - - const { id }: BoxItem = file; - try { - const response = await this.xhr.get({ - id: getTypedFileId(id), - url, - params: { categories: 'content_and_sharing' }, - headers: { [HEADER_BOX_VERSION]: '2025.0' }, - }); - const contentAndSharing = getProp(response, 'data.content_and_sharing', {}); - const isMigration = getProp(contentAndSharing, `${METADATA_NAMESPACE_MIGRATION_FIELD}.value`, false); - const isFinal = getProp(contentAndSharing, `${METADATA_NAMESPACE_FINAL_FIELD}.value`, false); - - if (isFinal) return METADATA_SCOPE_MODE_FINAL; - if (isMigration) return METADATA_SCOPE_MODE_MIGRATION; - return METADATA_SCOPE_MODE_SCOPED; - } catch (e) { - return null; - } + /** @see MetadataNamespaces.getMetadataNamespaceMode */ + getMetadataNamespaceMode(file: BoxItem, enterpriseNumericId: string): Promise { + return this.getNamespacesAPI().getMetadataNamespaceMode(file, enterpriseNumericId); } - /** - * Creates a new namespaced metadata template. - * Maps to POST /metadata_templates/schema with `namespace` in body (MIGRATION/FINAL mode). - * - * @param {Object} body - template create body (namespace, templateKey, displayName, hidden, fields) - * @param {Function} successCallback - called with the created template on success - * @param {Function} errorCallback - called with error on failure - * @return {Promise} - */ - async createMetadataTemplate( + /** @see MetadataNamespaces.createMetadataTemplate */ + createMetadataTemplate( file: BoxItem, body: Object, successCallback: Function, errorCallback: ElementsErrorCallback, ): Promise { - // TODO(MDX-2136): remove next two lines when namespace API is deployed - if (IS_NAMESPACE_API_MOCKED) { - mockCreateMetadataTemplate(file, body, successCallback); - return; - } - - const { id }: BoxItem = file; - this.errorCode = ERROR_CODE_CREATE_METADATA_TEMPLATE; - const url = `${this.getMetadataTemplateUrl()}/schema`; - try { - const response = await this.xhr.post({ url, id: getTypedFileId(id), data: body }); - if (!this.isDestroyed()) { - successCallback(getProp(response, 'data')); - } - } catch (e) { - errorCallback(e, this.errorCode); - } + return this.getNamespacesAPI().createMetadataTemplate(file, body, successCallback, errorCallback); } - /** - * Updates a namespaced metadata template via a list of patch operations. - * Maps to PUT /metadata_templates/{namespaceFqn}/{templateKey}/schema. - * - * @param {BoxItem} file - current file (typed id is used for token auth) - * @param {string} namespaceFqn - namespace FQN of the template - * @param {string} templateKey - template key - * @param {Array} patchItems - array of patch operations - * @param {Function} successCallback - called with the updated template on success - * @param {Function} errorCallback - called with error on failure - * @return {Promise} - */ - async updateMetadataTemplate( + /** @see MetadataNamespaces.updateMetadataTemplate */ + updateMetadataTemplate( file: BoxItem, namespaceFqn: string, templateKey: string, @@ -454,60 +342,19 @@ class Metadata extends File { successCallback: Function, errorCallback: ElementsErrorCallback, ): Promise { - // TODO(MDX-2136): remove next two lines when namespace API is deployed - if (IS_NAMESPACE_API_MOCKED) { - mockUpdateMetadataTemplate(file, namespaceFqn, templateKey, patchItems, successCallback); - return; - } - - const { id }: BoxItem = file; - this.errorCode = ERROR_CODE_UPDATE_METADATA_TEMPLATE; - const url = this.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); - try { - const response = await this.xhr.put({ - url, - id: getTypedFileId(id), - headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json' }, - data: patchItems, - }); - if (!this.isDestroyed()) { - successCallback(getProp(response, 'data')); - } - } catch (e) { - errorCallback(e, this.errorCode); - } + return this.getNamespacesAPI().updateMetadataTemplate( + file, + namespaceFqn, + templateKey, + patchItems, + successCallback, + errorCallback, + ); } - /** - * Fetches a template schema in the shape expected by MetadataTemplateEditor. - * The Box API returns `hidden` for templates; the editor expects `isHidden`. - * - * @param {string} namespaceFqn - namespace FQN of the template - * @param {string} templateKey - template key - * @return {Promise} MetadataTemplateApiResponse shape for the editor - */ - async getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { - // TODO(MDX-2136): remove the mock block when namespace API is deployed. - // Falls through to the real API when the template is not found in the mock store. - if (IS_NAMESPACE_API_MOCKED) { - const mockResult = mockGetTemplateSchemaForEditor(namespaceFqn, templateKey); - if (mockResult) return mockResult; - } - - const url = this.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); - const response = await this.xhr.get({ url }); - const data = getProp(response, 'data', {}); - // Normalize: Box template API uses `hidden`; the editor's MetadataTemplateApiResponse expects `isHidden`. - return { - namespace: data.namespace || namespaceFqn, - templateKey: data.templateKey, - displayName: data.displayName, - fields: (data.fields || []).map(f => ({ - ...f, - isHidden: f.isHidden != null ? f.isHidden : f.hidden ?? false, - })), - isHidden: data.isHidden != null ? data.isHidden : data.hidden ?? false, - }; + /** @see MetadataNamespaces.getTemplateSchemaForEditor */ + getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { + return this.getNamespacesAPI().getTemplateSchemaForEditor(namespaceFqn, templateKey); } /** @@ -676,6 +523,7 @@ class Metadata extends File { const url = instanceId ? this.getMetadataTemplateUrlForInstance(instanceId) : this.getMetadataTemplateUrlForScope(scope); + console.log('fetching templates for scope', scope, 'and instanceId', instanceId, 'url', url); try { templates = await this.xhr.get({ @@ -845,18 +693,25 @@ class Metadata extends File { return instances; } + /** @see metadataNamespaceUtils.getEnterpriseRootFromScopeOrNamespace */ + getEnterpriseRootFromScopeOrNamespace(scope: ?string, namespace: ?string): string | null { + return resolveEnterpriseRoot(scope, namespace); + } + /** * Finds template for a given metadata instance. * * @param {string} id - Box file id * @param {Object} instance - metadata instance * @param {Array} templates - metadata templates + * @param {string} [viewerEnterpriseFqn] - viewer's enterprise root FQN * @return {Object|undefined} template for metadata instance */ async getTemplateForInstance( id: string, instance: MetadataInstanceV2, templates: Array, + viewerEnterpriseFqn?: string, ): Promise { const instanceId = instance.$id; const templateKey = instance.$template; @@ -876,15 +731,25 @@ class Metadata extends File { template = templates.find(t => t.templateKey === templateKey && t.namespace === namespace); } - // Enterprise scopes are always enterprise_XXXXX; guard against - // namespace-only instances where $scope is undefined. - if (!template && scope && scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { - // Any missing template is likely from another enterprise (e.g. collaborated file); - // Templates array has no pagination so we can assume cross-enterprise as it contains all templates. - const crossEnterpriseTemplates = await this.getTemplates(id, scope, instanceId, true); - // The API always returns an array of at most one item - const crossEnterpriseTemplate = crossEnterpriseTemplates[0]; - return { template: crossEnterpriseTemplate, isExternallyOwned: true }; + if (!template && instanceId) { + const instanceEnterpriseRoot = resolveEnterpriseRoot(scope, namespace); + if (instanceEnterpriseRoot) { + const isExternallyOwned = isTemplateExternallyOwned( + instanceEnterpriseRoot, + viewerEnterpriseFqn, + !!(scope && scope.startsWith(METADATA_SCOPE_ENTERPRISE)), + ); + + const fetchedTemplates = await this.getTemplates( + id, + scope || namespace || instanceEnterpriseRoot, + instanceId, + isExternallyOwned, + ); + // The API always returns an array of at most one item + const fetchedTemplate = fetchedTemplates[0]; + return fetchedTemplate ? { template: fetchedTemplate, isExternallyOwned } : null; + } } return template ? { template, isExternallyOwned: false } : null; } @@ -907,6 +772,7 @@ class Metadata extends File { enterpriseTemplates: Array, globalTemplates: Array, canEdit: boolean, + viewerEnterpriseFqn?: string, ): Promise> { // All usable templates for metadata instances const templates: Array = [customPropertiesTemplate].concat( @@ -918,7 +784,7 @@ class Metadata extends File { const editors: Array = []; await Promise.all( instances.map(async instance => { - const result = await this.getTemplateForInstance(id, instance, templates); + const result = await this.getTemplateForInstance(id, instance, templates, viewerEnterpriseFqn); if (result && result.template) { editors.push(this.createEditor(instance, result.template, canEdit)); } @@ -1023,6 +889,7 @@ class Metadata extends File { globalTemplates: Array, canEdit: boolean, isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, + viewerEnterpriseFqn?: string, ): Promise> { // Get all usable templates for metadata instances const templates: Array = [customPropertiesTemplate].concat( @@ -1035,7 +902,7 @@ class Metadata extends File { await Promise.all( instances.map(async instance => { - const result = await this.getTemplateForInstance(id, instance, templates); + const result = await this.getTemplateForInstance(id, instance, templates, viewerEnterpriseFqn); if (result && result.template) { templateInstances.push( this.createTemplateInstance( @@ -1053,29 +920,55 @@ class Metadata extends File { return templateInstances; } + /** @see metadataNamespaceUtils.getEnterpriseNamespaceFromInstances */ + getEnterpriseNamespaceFromInstances(instances: Array): string | null { + return resolveEnterpriseNamespaceFromInstances(instances); + } + /** - * Extracts the full enterprise scope FQN (e.g. `enterprise_123456`) from a list - * of metadata instances. Used in MIGRATION/FINAL mode where the `'enterprise'` - * shorthand is no longer a valid template-fetch scope. - * - * Checks `$scope` first (populated in MIGRATION mode) and falls back to the - * leading `enterprise_XXXXX` segment of `$namespace` (used in FINAL mode). - * - * @param {Array} instances - raw metadata instances - * @return {string|null} full enterprise FQN or null when none found + * SCOPED mode: fetch instances + global + enterprise templates in parallel. */ - getEnterpriseScopeFromInstances(instances: Array): string | null { - for (const inst of instances) { - const { $scope, $namespace } = inst; - if ($scope && $scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { - return $scope; - } - if ($namespace && $namespace.startsWith(METADATA_SCOPE_ENTERPRISE)) { - // Namespace FQNs may be "enterprise_123/key" — take the leading segment. - return $namespace.split('/')[0]; - } - } - return null; + async fetchTemplatesAndInstancesScoped( + id: string, + hasMetadataFeature: boolean, + isMetadataRedesign: boolean, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean, + ): Promise<{ + instances: Array, + globalTemplates: Array, + enterpriseTemplates: Array, + }> { + const [instances, globalTemplates, enterpriseTemplates] = await Promise.all([ + this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), + this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), + hasMetadataFeature ? this.getTemplates(id, METADATA_SCOPE_ENTERPRISE) : Promise.resolve([]), + ]); + return { instances, globalTemplates, enterpriseTemplates }; + } + + /** + * MIGRATION/FINAL mode: `'enterprise'` shorthand is invalid. Prefer the + * caller's enterprise root FQN; fall back to deriving it from instances. + */ + async fetchTemplatesAndInstancesNamespaced( + id: string, + hasMetadataFeature: boolean, + isMetadataRedesign: boolean, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean, + enterpriseFqn?: string, + ): Promise<{ + instances: Array, + globalTemplates: Array, + enterpriseTemplates: Array, + }> { + const [instances, globalTemplates] = await Promise.all([ + this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), + this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), + ]); + const enterpriseNamespace = enterpriseFqn || resolveEnterpriseNamespaceFromInstances(instances); + const enterpriseTemplates = + hasMetadataFeature && enterpriseNamespace ? await this.getTemplates(id, enterpriseNamespace) : []; + return { instances, globalTemplates, enterpriseTemplates }; } /** @@ -1099,7 +992,7 @@ class Metadata extends File { }) => void, errorCallback: ElementsErrorCallback, hasMetadataFeature: boolean, - options: RequestOptions = {}, + options: MetadataGetOptions = {}, isMetadataRedesign: boolean = false, isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise { @@ -1134,31 +1027,31 @@ class Metadata extends File { try { const customPropertiesTemplate: MetadataTemplate = this.getCustomPropertiesTemplate(); - // In namespace modes (MIGRATION/FINAL) the 'enterprise' shorthand is no longer - // valid. Fetch instances first so we can derive the full enterprise scope FQN - // (e.g. enterprise_123456) from the instances' $scope / $namespace fields. - // In SCOPED mode all three fetches run in parallel for performance. - let instances; - let globalTemplates; - let enterpriseTemplates; - if (this.metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED) { - [instances, globalTemplates] = await Promise.all([ - this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), - this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), - ]); - const enterpriseScope = this.getEnterpriseScopeFromInstances(instances); - enterpriseTemplates = - hasMetadataFeature && enterpriseScope ? await this.getTemplates(id, enterpriseScope) : []; - } else { - [instances, globalTemplates, enterpriseTemplates] = await Promise.all([ - this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), - this.getTemplates(id, this.getScopeOrNamespace(METADATA_SCOPE_GLOBAL)), - hasMetadataFeature ? this.getTemplates(id, METADATA_SCOPE_ENTERPRISE) : Promise.resolve([]), - ]); + // Prefer the mode from this call (sidebar) so URL helpers and template + // fetches stay in sync with the enterprise configuration API. + if (options.metadataNamespaceMode) { + this.metadataNamespaceMode = options.metadataNamespaceMode; } + const { instances, globalTemplates, enterpriseTemplates } = + this.metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED + ? await this.fetchTemplatesAndInstancesNamespaced( + id, + hasMetadataFeature, + isMetadataRedesign, + isBoundingBoxOrConfidenceScoreReviewEnabled, + options.enterpriseFqn, + ) + : await this.fetchTemplatesAndInstancesScoped( + id, + hasMetadataFeature, + isMetadataRedesign, + isBoundingBoxOrConfidenceScoreReviewEnabled, + ); + // Filter out classification const filteredInstances = this.extractClassification(id, instances); + const viewerEnterpriseFqn = options.enterpriseFqn; const templateInstances = isMetadataRedesign ? await this.getTemplateInstances( @@ -1169,6 +1062,7 @@ class Metadata extends File { globalTemplates, !!permissions.can_upload, isBoundingBoxOrConfidenceScoreReviewEnabled, + viewerEnterpriseFqn, ) : []; const editors = !isMetadataRedesign @@ -1179,6 +1073,7 @@ class Metadata extends File { enterpriseTemplates, globalTemplates, !!permissions.can_upload, + viewerEnterpriseFqn, ) : []; diff --git a/src/api/MetadataNamespaces.js b/src/api/MetadataNamespaces.js new file mode 100644 index 0000000000..c91e87fb9c --- /dev/null +++ b/src/api/MetadataNamespaces.js @@ -0,0 +1,227 @@ +/** + * @flow + * @file Namespace-migration Metadata API surface (list/create/update + mode). + * + * Collaborator owned by Metadata.js — keeps mid/post-migration HTTP out of the + * core scoped metadata flows. Methods are delegated from Metadata for a stable + * public API (`api.getMetadataAPI().listNamespaces(...)`). + */ + +import getProp from 'lodash/get'; +import { getTypedFileId } from '../utils/file'; +import { + ERROR_CODE_CREATE_METADATA_TEMPLATE, + ERROR_CODE_UPDATE_METADATA_TEMPLATE, + HEADER_BOX_VERSION, + HEADER_CONTENT_TYPE, + METADATA_NAMESPACE_FINAL_FIELD, + METADATA_NAMESPACE_MIGRATION_FIELD, +} from '../constants'; +import type { ElementsErrorCallback } from '../common/types/api'; +import type { BoxItem } from '../common/types/core'; +import type APICache from '../utils/Cache'; +import { resolveMetadataNamespaceMode } from './metadataNamespaceUtils'; +// TODO(MDX-2136): remove this import when namespace API is deployed +import { + IS_NAMESPACE_API_MOCKED, + mockListNamespaces, + mockListTemplatesForNamespace, + mockCreateMetadataTemplate, + mockUpdateMetadataTemplate, + mockGetTemplateSchemaForEditor, +} from './metadataNamespaceMocks'; + +/** Minimal host surface MetadataNamespaces needs from Metadata. */ +export type MetadataNamespaceHost = { + errorCode: string, + getBaseApiUrl: () => string, + getCache: () => APICache, + getMetadataTemplateSchemaCacheKey: (templateKey: string, scope?: string) => string, + getMetadataTemplateSchemaUrl: (templateKey: string, scope?: string) => string, + getMetadataTemplateUrl: () => string, + getMetadataTemplateUrlForScope: (scope: string) => string, + isDestroyed: () => boolean, + xhr: { + get: (config: Object) => Promise, + post: (config: Object) => Promise, + put: (config: Object) => Promise, + }, +}; + +export default class MetadataNamespaces { + host: MetadataNamespaceHost; + + constructor(host: MetadataNamespaceHost) { + this.host = host; + } + + getMetadataNamespacesUrl(namespaceFqn: string): string { + return `${this.host.getBaseApiUrl()}/metadata_namespaces/${namespaceFqn}`; + } + + getEnterpriseConfigurationsUrl(enterpriseNumericId: string): string { + return `${this.host.getBaseApiUrl()}/enterprise_configurations/${enterpriseNumericId}`; + } + + /** + * Lists child namespaces under a given namespace FQN. + */ + async listNamespaces( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + // TODO(MDX-2136): remove next line when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) return mockListNamespaces(file, namespaceFqn, params); + + const { id }: BoxItem = file; + const url = this.getMetadataNamespacesUrl(namespaceFqn); + try { + const response = await this.host.xhr.get({ + url, + id: getTypedFileId(id), + params: { limit: params.limit, marker: params.marker }, + }); + return getProp(response, 'data', { entries: [] }); + } catch (e) { + return { entries: [] }; + } + } + + /** + * Lists templates under a namespace FQN with cursor pagination. + */ + async listTemplatesForNamespace( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + // TODO(MDX-2136): remove next line when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) return mockListTemplatesForNamespace(file, namespaceFqn, params); + + const { id }: BoxItem = file; + const url = this.host.getMetadataTemplateUrlForScope(namespaceFqn); + try { + const response = await this.host.xhr.get({ + url, + id: getTypedFileId(id), + params: { limit: params.limit, marker: params.marker }, + }); + return getProp(response, 'data', { entries: [] }); + } catch (e) { + return { entries: [] }; + } + } + + /** + * Fetches the metadata namespace migration mode for the given enterprise. + * Returns `null` when the request fails so callers can fall back safely. + */ + async getMetadataNamespaceMode(file: BoxItem, enterpriseNumericId: string): Promise { + const url = this.getEnterpriseConfigurationsUrl(enterpriseNumericId); + const { id }: BoxItem = file; + try { + const response = await this.host.xhr.get({ + id: getTypedFileId(id), + url, + params: { categories: 'content_and_sharing' }, + headers: { [HEADER_BOX_VERSION]: '2025.0' }, + }); + const contentAndSharing = getProp(response, 'data.content_and_sharing', {}); + const isMigration = getProp(contentAndSharing, `${METADATA_NAMESPACE_MIGRATION_FIELD}.value`, false); + const isFinal = getProp(contentAndSharing, `${METADATA_NAMESPACE_FINAL_FIELD}.value`, false); + return resolveMetadataNamespaceMode(isMigration, isFinal); + } catch (e) { + return null; + } + } + + /** + * Creates a new namespaced metadata template. + */ + async createMetadataTemplate( + file: BoxItem, + body: Object, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + // TODO(MDX-2136): remove next two lines when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) { + mockCreateMetadataTemplate(file, body, successCallback); + return; + } + + const { id }: BoxItem = file; + this.host.errorCode = ERROR_CODE_CREATE_METADATA_TEMPLATE; + const url = `${this.host.getMetadataTemplateUrl()}/schema`; + try { + const response = await this.host.xhr.post({ url, id: getTypedFileId(id), data: body }); + if (!this.host.isDestroyed()) { + successCallback(getProp(response, 'data')); + } + } catch (e) { + errorCallback(e, this.host.errorCode); + } + } + + /** + * Updates a namespaced metadata template via patch operations. + */ + async updateMetadataTemplate( + file: BoxItem, + namespaceFqn: string, + templateKey: string, + patchItems: Array, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + // TODO(MDX-2136): remove next two lines when namespace API is deployed + if (IS_NAMESPACE_API_MOCKED) { + mockUpdateMetadataTemplate(file, namespaceFqn, templateKey, patchItems, successCallback); + return; + } + + const { id }: BoxItem = file; + this.host.errorCode = ERROR_CODE_UPDATE_METADATA_TEMPLATE; + const url = this.host.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); + try { + const response = await this.host.xhr.put({ + url, + id: getTypedFileId(id), + headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json' }, + data: patchItems, + }); + if (!this.host.isDestroyed()) { + this.host.getCache().unset(this.host.getMetadataTemplateSchemaCacheKey(templateKey, namespaceFqn)); + successCallback(getProp(response, 'data')); + } + } catch (e) { + errorCallback(e, this.host.errorCode); + } + } + + /** + * Fetches a template schema in the shape expected by MetadataTemplateEditor. + */ + async getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { + // TODO(MDX-2136): remove the mock block when namespace API is deployed. + if (IS_NAMESPACE_API_MOCKED) { + const mockResult = mockGetTemplateSchemaForEditor(namespaceFqn, templateKey); + if (mockResult) return mockResult; + } + + const url = this.host.getMetadataTemplateSchemaUrl(templateKey, namespaceFqn); + const response = await this.host.xhr.get({ url }); + const data = getProp(response, 'data', {}); + return { + namespace: data.namespace || namespaceFqn, + templateKey: data.templateKey, + displayName: data.displayName, + fields: (data.fields || []).map(f => ({ + ...f, + isHidden: f.isHidden != null ? f.isHidden : f.hidden ?? false, + })), + isHidden: data.isHidden != null ? data.isHidden : data.hidden ?? false, + }; + } +} diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 066c632fc5..8f336707e2 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -759,6 +759,27 @@ describe('api/Metadata', () => { }); }); + describe('getEnterpriseNamespaceFromInstances()', () => { + test('should prefer $scope when it is an enterprise scope', () => { + expect( + metadata.getEnterpriseNamespaceFromInstances([ + { $scope: 'enterprise_999', $namespace: 'enterprise_123.legal' }, + ]), + ).toBe('enterprise_999'); + }); + + test('should extract enterprise root namespace from dot-delimited namespace', () => { + expect(metadata.getEnterpriseNamespaceFromInstances([{ $namespace: 'enterprise_123456.legal' }])).toBe( + 'enterprise_123456', + ); + }); + + test('should return null when no enterprise scope or namespace is present', () => { + expect(metadata.getEnterpriseNamespaceFromInstances([{ $scope: 'global' }])).toBeNull(); + expect(metadata.getEnterpriseNamespaceFromInstances([])).toBeNull(); + }); + }); + describe('getSchemaByTemplateKey()', () => { test('should return metadata template for provided template key', async () => { const metadataTemplate = 'metadataTemplate'; @@ -1067,6 +1088,50 @@ describe('api/Metadata', () => { expect(result.template).toBe(namespaceTemplate); expect(result.isExternallyOwned).toBe(false); }); + + test('should fetch schema by instance id for unmatched same-enterprise child namespace', async () => { + const fetchedTemplate = { + id: 9, + templateKey: 'childTpl', + namespace: 'enterprise_123.legal', + }; + metadata.getTemplates = jest.fn().mockResolvedValueOnce([fetchedTemplate]); + const result = await metadata.getTemplateForInstance( + 'id', + { + $id: 'instanceId', + $template: 'childTpl', + $namespace: 'enterprise_123.legal', + }, + templatesFromServer, + 'enterprise_123', + ); + expect(result.template).toBe(fetchedTemplate); + expect(result.isExternallyOwned).toBe(false); + expect(metadata.getTemplates).toBeCalledWith('id', 'enterprise_123.legal', 'instanceId', false); + }); + + test('should mark namespace-only foreign enterprise templates as externally owned', async () => { + const fetchedTemplate = { + id: 10, + templateKey: 'foreignTpl', + namespace: 'enterprise_999.legal', + }; + metadata.getTemplates = jest.fn().mockResolvedValueOnce([fetchedTemplate]); + const result = await metadata.getTemplateForInstance( + 'id', + { + $id: 'instanceId', + $template: 'foreignTpl', + $namespace: 'enterprise_999.legal', + }, + templatesFromServer, + 'enterprise_123', + ); + expect(result.template).toBe(fetchedTemplate); + expect(result.isExternallyOwned).toBe(true); + expect(metadata.getTemplates).toBeCalledWith('id', 'enterprise_999.legal', 'instanceId', true); + }); }); describe('getEditors()', () => { @@ -1360,6 +1425,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1375,6 +1441,50 @@ describe('api/Metadata', () => { templates: 'templates', }); }); + test('should use authoritative enterpriseFqn in MIGRATION/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([]); + metadata.getEnterpriseNamespaceFromInstances = jest.fn(); + + await metadata.getMetadata(file, jest.fn(), jest.fn(), true, { + enterpriseFqn: 'enterprise_123456', + metadataNamespaceMode: 'MIGRATION', + }); + + expect(metadata.metadataNamespaceMode).toBe('MIGRATION'); + expect(metadata.getTemplates).toHaveBeenCalledWith(file.id, 'global'); + expect(metadata.getTemplates).toHaveBeenCalledWith(file.id, 'enterprise_123456'); + expect(metadata.getEnterpriseNamespaceFromInstances).not.toHaveBeenCalled(); + expect(metadata.getEditors).toHaveBeenCalledWith( + file.id, + [], + 'custom', + 'enterpriseRoot', + 'global', + true, + 'enterprise_123456', + ); + expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterpriseRoot', true, false); + }); test('should make request and update cache and call success handler for Metadata Redesign', async () => { const file = { id: 'id', @@ -1417,6 +1527,7 @@ describe('api/Metadata', () => { 'global', true, false, + undefined, ); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); expect(metadata.successHandler).toHaveBeenCalledWith({ @@ -1466,6 +1577,7 @@ describe('api/Metadata', () => { 'global', true, true, + undefined, ); }); @@ -1510,6 +1622,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1568,6 +1681,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1625,6 +1739,7 @@ describe('api/Metadata', () => { [], 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', [], false, true); @@ -1720,6 +1835,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalled(); diff --git a/src/api/__tests__/metadataNamespaceUtils.test.js b/src/api/__tests__/metadataNamespaceUtils.test.js new file mode 100644 index 0000000000..ae12ba7009 --- /dev/null +++ b/src/api/__tests__/metadataNamespaceUtils.test.js @@ -0,0 +1,75 @@ +import { + getEnterpriseNamespaceFromInstances, + getEnterpriseRootFromScopeOrNamespace, + isTemplateExternallyOwned, + resolveMetadataNamespaceMode, + resolveScopeOrNamespace, +} from '../metadataNamespaceUtils'; +import { + METADATA_NAMESPACE_GLOBAL, + METADATA_SCOPE_MODE_FINAL, + METADATA_SCOPE_MODE_MIGRATION, + METADATA_SCOPE_MODE_SCOPED, +} from '../../constants'; + +describe('api/metadataNamespaceUtils', () => { + describe('getEnterpriseRootFromScopeOrNamespace()', () => { + test('should prefer scope when present', () => { + expect(getEnterpriseRootFromScopeOrNamespace('enterprise_999', 'enterprise_123.legal')).toBe( + 'enterprise_999', + ); + }); + + test('should extract root from dot-delimited namespace', () => { + expect(getEnterpriseRootFromScopeOrNamespace(undefined, 'enterprise_123.legal')).toBe('enterprise_123'); + }); + + test('should return null when neither is an enterprise value', () => { + expect(getEnterpriseRootFromScopeOrNamespace('global', 'box.metadata')).toBeNull(); + }); + }); + + describe('getEnterpriseNamespaceFromInstances()', () => { + test('should return the first enterprise root found', () => { + expect( + getEnterpriseNamespaceFromInstances([{ $scope: 'global' }, { $namespace: 'enterprise_123.legal' }]), + ).toBe('enterprise_123'); + }); + }); + + describe('resolveMetadataNamespaceMode()', () => { + test.each` + isMigration | isFinal | expected + ${false} | ${false} | ${METADATA_SCOPE_MODE_SCOPED} + ${true} | ${false} | ${METADATA_SCOPE_MODE_MIGRATION} + ${true} | ${true} | ${METADATA_SCOPE_MODE_FINAL} + ${false} | ${true} | ${METADATA_SCOPE_MODE_FINAL} + `('should resolve $expected', ({ isMigration, isFinal, expected }) => { + expect(resolveMetadataNamespaceMode(isMigration, isFinal)).toBe(expected); + }); + }); + + describe('resolveScopeOrNamespace()', () => { + test('should map global to box.metadata in FINAL mode', () => { + expect(resolveScopeOrNamespace(METADATA_SCOPE_MODE_FINAL, 'global')).toBe(METADATA_NAMESPACE_GLOBAL); + }); + + test('should prefer namespace when scope is absent', () => { + expect(resolveScopeOrNamespace(METADATA_SCOPE_MODE_MIGRATION, undefined, 'enterprise_123.legal')).toBe( + 'enterprise_123.legal', + ); + }); + }); + + describe('isTemplateExternallyOwned()', () => { + test('should compare enterprise roots when viewer FQN is known', () => { + expect(isTemplateExternallyOwned('enterprise_999', 'enterprise_123', false)).toBe(true); + expect(isTemplateExternallyOwned('enterprise_123', 'enterprise_123', false)).toBe(false); + }); + + test('should fall back to scoped-miss legacy behaviour without viewer FQN', () => { + expect(isTemplateExternallyOwned('enterprise_999', undefined, true)).toBe(true); + expect(isTemplateExternallyOwned('enterprise_123', undefined, false)).toBe(false); + }); + }); +}); diff --git a/src/api/metadataNamespaceMocks.js b/src/api/metadataNamespaceMocks.js index 2b0517bff8..d67699ba61 100644 --- a/src/api/metadataNamespaceMocks.js +++ b/src/api/metadataNamespaceMocks.js @@ -196,6 +196,8 @@ export function mockUpdateMetadataTemplate( } }); templates[idx] = updated; + successCallback(updated); + return; } successCallback({ type: 'metadata_template', namespace: namespaceFqn, templateKey }); } diff --git a/src/api/metadataNamespaceUtils.js b/src/api/metadataNamespaceUtils.js new file mode 100644 index 0000000000..dcc5c137d3 --- /dev/null +++ b/src/api/metadataNamespaceUtils.js @@ -0,0 +1,87 @@ +/** + * @flow + * @file Pure helpers for metadata namespace migration (SCOPED / MIGRATION / FINAL). + * Keep I/O out of this file — Metadata.js / MetadataNamespaces.js own network calls. + */ + +import { + METADATA_NAMESPACE_GLOBAL, + METADATA_SCOPE_ENTERPRISE, + METADATA_SCOPE_GLOBAL, + METADATA_SCOPE_MODE_FINAL, + METADATA_SCOPE_MODE_MIGRATION, + METADATA_SCOPE_MODE_SCOPED, +} from '../constants'; +import type { MetadataInstanceV2 } from '../common/types/metadata'; + +/** + * Resolves the enterprise root FQN (`enterprise_123`) from a scope and/or + * namespace value. Child namespaces like `enterprise_123.legal` resolve to + * `enterprise_123`. + */ +export function getEnterpriseRootFromScopeOrNamespace(scope: ?string, namespace: ?string): string | null { + if (scope && scope.startsWith(METADATA_SCOPE_ENTERPRISE)) { + return scope; + } + if (namespace && namespace.startsWith(METADATA_SCOPE_ENTERPRISE)) { + return namespace.split('.')[0]; + } + return null; +} + +/** + * Extracts the enterprise root namespace FQN from a list of metadata instances. + * Prefers `$scope`, then the leading segment of `$namespace`. + */ +export function getEnterpriseNamespaceFromInstances(instances: Array): string | null { + for (const inst of instances) { + const root = getEnterpriseRootFromScopeOrNamespace(inst.$scope, inst.$namespace); + if (root) { + return root; + } + } + return null; +} + +/** + * Maps enterprise_configurations content_and_sharing flags to a migration mode. + */ +export function resolveMetadataNamespaceMode(isMigration: boolean, isFinal: boolean): string { + if (isFinal) return METADATA_SCOPE_MODE_FINAL; + if (isMigration) return METADATA_SCOPE_MODE_MIGRATION; + return METADATA_SCOPE_MODE_SCOPED; +} + +/** + * Resolves the URL path segment for a scope/namespace given the current mode. + * + * - In FINAL mode, legacy `global` becomes `box.metadata`. + * - When scope is absent, the namespace FQN is used. + */ +export function resolveScopeOrNamespace(metadataNamespaceMode: string, scope: ?string, namespace?: ?string): string { + if (metadataNamespaceMode === METADATA_SCOPE_MODE_FINAL && scope === METADATA_SCOPE_GLOBAL) { + return METADATA_NAMESPACE_GLOBAL; + } + if (!scope && namespace) { + return namespace; + } + return scope || ''; +} + +/** + * Whether a missing local template should be treated as externally owned. + * + * When the viewer's enterprise FQN is known, compare enterprise roots. + * Without it, preserve legacy behaviour: a scoped miss is cross-enterprise; + * a namespace-only miss is treated as same-enterprise (child namespace). + */ +export function isTemplateExternallyOwned( + instanceEnterpriseRoot: string, + viewerEnterpriseFqn: ?string, + hasEnterpriseScope: boolean, +): boolean { + if (viewerEnterpriseFqn) { + return instanceEnterpriseRoot !== viewerEnterpriseFqn; + } + return hasEnterpriseScope; +} diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 9f270ba9ea..378d6bb430 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -35,7 +35,6 @@ import { ORIGIN_METADATA_SIDEBAR_REDESIGN, SIDEBAR_VIEW_METADATA, ERROR_CODE_METADATA_STRUCTURED_TEXT_REP, - METADATA_SCOPE_MODE_SCOPED, } from '../../constants'; import { EVENT_JS_READY } from '../common/logger/constants'; import { mark } from '../../utils/performance'; @@ -60,8 +59,7 @@ import useMetadataFieldSelection from './hooks/useMetadataFieldSelection'; import useMetadataSidebarUnsavedChangesGuard from './hooks/useMetadataSidebarUnsavedChangesGuard'; import useMetadataTemplateEditor from './hooks/useMetadataTemplateEditor'; import useMetadataTemplateItemsService from './hooks/useMetadataTemplateItemsService'; -import useMetadataNamespaceMode from './hooks/useMetadataNamespaceMode'; -import useCurrentUserEnterpriseId from './hooks/useCurrentUserEnterpriseId'; +import useMetadataNamespaceContext from './hooks/useMetadataNamespaceContext'; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}_${EVENT_JS_READY}`; @@ -141,6 +139,11 @@ function MetadataSidebarRedesign({ const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundingBoxEnabled || isConfidenceScoreReviewEnabled; + const { enterpriseId, metadataNamespaceMode, isTemplateManagementEnabled } = useMetadataNamespaceContext( + api, + fileId, + ); + const { clearExtractError, extractSuggestions, @@ -162,6 +165,10 @@ function MetadataSidebarRedesign({ isFeatureEnabled, isConfidenceScoreReviewEnabled, isBoundingBoxEnabled, + { + enterpriseFqn: enterpriseId, + metadataNamespaceMode, + }, ); const isSessionInitiated = useRef(false); @@ -179,21 +186,6 @@ function MetadataSidebarRedesign({ // Template management — gated behind namespace migration mode (MIGRATION or FINAL). const [isDropdownOpen, setIsDropdownOpen] = useState(undefined); - // Fetch the migration mode from the enterprise configurations API. - // Gated behind the enterprise_metadata_namespaces_opt_in split treatment — when the - // flag is off the hook skips the API call and returns null (= legacy SCOPED behaviour). - // Enterprise ID comes from the current user (not templates), so mode can be resolved - // even when the file has no enterprise templates yet. - const isNamespacesOptInEnabled: boolean = useFeatureEnabled('metadata.namespacesOptIn.enabled'); - const { enterpriseId, enterpriseNumericId } = useCurrentUserEnterpriseId(api, file, isNamespacesOptInEnabled); - const { mode: metadataNamespaceMode } = useMetadataNamespaceMode( - file, - api, - enterpriseNumericId, - isNamespacesOptInEnabled, - ); - const isTemplateManagementEnabled = !!metadataNamespaceMode && metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED; - // API-backed ItemsService for MetadataTemplateBrowser — only active when template management is enabled. const itemsService = useMetadataTemplateItemsService( api, diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx index 7002a171c4..88df7cb2e5 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx @@ -10,10 +10,12 @@ import { } from '../MetadataSidebarRedesign'; import useSidebarMetadataFetcher, { STATUS } from '../hooks/useSidebarMetadataFetcher'; import useMetadataFieldSelection from '../hooks/useMetadataFieldSelection'; +import useMetadataNamespaceContext from '../hooks/useMetadataNamespaceContext'; import type { MetadataInstanceEditorProps } from '../MetadataInstanceEditor'; jest.mock('../hooks/useSidebarMetadataFetcher'); jest.mock('../hooks/useMetadataFieldSelection'); +jest.mock('../hooks/useMetadataNamespaceContext'); // Stubbing the editor lets us inspect the props the sidebar hands to it // without pulling the full @box/metadata-editor form tree into the test. @@ -29,6 +31,9 @@ const mockUseSidebarMetadataFetcher = useSidebarMetadataFetcher as jest.MockedFu const mockUseMetadataFieldSelection = useMetadataFieldSelection as jest.MockedFunction< typeof useMetadataFieldSelection >; +const mockUseMetadataNamespaceContext = useMetadataNamespaceContext as jest.MockedFunction< + typeof useMetadataNamespaceContext +>; const mockFile = { id: '123', permissions: { can_upload: true } }; const mockTemplates: MetadataTemplate[] = [ @@ -101,6 +106,13 @@ describe('MetadataSidebarRedesign taxonomy picker wiring', () => { handleSelectMetadataField: jest.fn(), }); + mockUseMetadataNamespaceContext.mockReturnValue({ + enterpriseId: undefined, + metadataNamespaceMode: null, + isTemplateManagementEnabled: false, + isLoading: false, + }); + mockUseSidebarMetadataFetcher.mockReturnValue({ clearExtractError: jest.fn(), extractSuggestions: jest.fn(), diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 169cf1e48f..21706154ff 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -11,6 +11,7 @@ import { } from '../MetadataSidebarRedesign'; import useSidebarMetadataFetcher, { STATUS } from '../hooks/useSidebarMetadataFetcher'; import useMetadataFieldSelection from '../hooks/useMetadataFieldSelection'; +import useMetadataNamespaceContext from '../hooks/useMetadataNamespaceContext'; jest.mock('../hooks/useSidebarMetadataFetcher'); const mockUseSidebarMetadataFetcher = useSidebarMetadataFetcher as jest.MockedFunction< @@ -22,6 +23,11 @@ const mockUseMetadataFieldSelection = useMetadataFieldSelection as jest.MockedFu typeof useMetadataFieldSelection >; +jest.mock('../hooks/useMetadataNamespaceContext'); +const mockUseMetadataNamespaceContext = useMetadataNamespaceContext as jest.MockedFunction< + typeof useMetadataNamespaceContext +>; + const getStructuredTextRep = jest.fn().mockResolvedValue('structured-text-rep'); const api = { options: { @@ -143,6 +149,13 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleSelectMetadataField: jest.fn(), }); + mockUseMetadataNamespaceContext.mockReturnValue({ + enterpriseId: undefined, + metadataNamespaceMode: null, + isTemplateManagementEnabled: false, + isLoading: false, + }); + mockUseSidebarMetadataFetcher.mockReturnValue({ clearExtractError: jest.fn(), extractSuggestions: jest.fn(), @@ -624,6 +637,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), // isFeatureEnabled true, // isConfidenceScoreReviewEnabled false, // isBoundingBoxEnabled + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -638,6 +652,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), false, false, + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -652,6 +667,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), // isFeatureEnabled false, // isConfidenceScoreReviewEnabled true, // isBoundingBoxEnabled + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -666,6 +682,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), false, false, + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); diff --git a/src/elements/content-sidebar/__tests__/useMetadataNamespaceContext.test.tsx b/src/elements/content-sidebar/__tests__/useMetadataNamespaceContext.test.tsx new file mode 100644 index 0000000000..2479cf333b --- /dev/null +++ b/src/elements/content-sidebar/__tests__/useMetadataNamespaceContext.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { METADATA_SCOPE_ENTERPRISE } from '../../../constants'; +import { FeatureProvider } from '../../common/feature-checking'; +import { act, renderHook, waitFor } from '../../../test-utils/testing-library'; +import useMetadataNamespaceContext from '../hooks/useMetadataNamespaceContext'; + +describe('useMetadataNamespaceContext', () => { + const fileId = 'file-123'; + const enterpriseNumericId = '173733877'; + const enterpriseId = `${METADATA_SCOPE_ENTERPRISE}_${enterpriseNumericId}`; + + let getUser: jest.Mock; + let getMetadataNamespaceMode: jest.Mock; + let api: { getUsersAPI: jest.Mock; getMetadataAPI: jest.Mock }; + + const renderContextHook = (features: Record = {}, hookFileId = fileId) => + renderHook(() => useMetadataNamespaceContext(api as never, hookFileId), { + wrapper: ({ children }) => {children}, + }); + + beforeEach(() => { + getUser = jest.fn(); + getMetadataNamespaceMode = jest.fn().mockResolvedValue('SCOPED'); + api = { + getUsersAPI: jest.fn().mockReturnValue({ getUser }), + getMetadataAPI: jest.fn().mockReturnValue({ getMetadataNamespaceMode }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should skip fetches and disable template management when namespaces opt-in is off', () => { + const { result } = renderContextHook({ 'metadata.namespacesOptIn.enabled': false }); + + expect(api.getUsersAPI).not.toHaveBeenCalled(); + expect(api.getMetadataAPI).not.toHaveBeenCalled(); + expect(result.current).toEqual({ + enterpriseId: undefined, + metadataNamespaceMode: null, + isTemplateManagementEnabled: false, + isLoading: false, + }); + }); + + test('should resolve enterprise id and SCOPED mode without enabling template management', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ enterprise: { id: enterpriseNumericId } }); + }); + getMetadataNamespaceMode.mockResolvedValue('SCOPED'); + + const { result } = renderContextHook({ 'metadata.namespacesOptIn.enabled': true }); + + await waitFor(() => { + expect(result.current.metadataNamespaceMode).toBe('SCOPED'); + }); + + expect(getUser).toHaveBeenCalledWith(fileId, expect.any(Function), expect.any(Function), expect.any(Object)); + expect(getMetadataNamespaceMode).toHaveBeenCalledWith({ id: fileId }, enterpriseNumericId); + expect(result.current).toEqual({ + enterpriseId, + metadataNamespaceMode: 'SCOPED', + isTemplateManagementEnabled: false, + isLoading: false, + }); + }); + + test('should enable template management for MIGRATION mode', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ enterprise: { id: enterpriseNumericId } }); + }); + getMetadataNamespaceMode.mockResolvedValue('MIGRATION'); + + const { result } = renderContextHook({ 'metadata.namespacesOptIn.enabled': true }); + + await waitFor(() => { + expect(result.current).toEqual({ + enterpriseId, + metadataNamespaceMode: 'MIGRATION', + isTemplateManagementEnabled: true, + isLoading: false, + }); + }); + }); + + test('should enable template management for FINAL mode', async () => { + getUser.mockImplementation((_id, successCallback) => { + successCallback({ enterprise: { id: enterpriseNumericId } }); + }); + getMetadataNamespaceMode.mockResolvedValue('FINAL'); + + const { result } = renderContextHook({ 'metadata.namespacesOptIn.enabled': true }); + + await waitFor(() => { + expect(result.current.isTemplateManagementEnabled).toBe(true); + expect(result.current.metadataNamespaceMode).toBe('FINAL'); + }); + }); + + test('should not fetch namespace mode until enterprise id is available', async () => { + let resolveUser: (user: { enterprise: { id: string } }) => void = () => undefined; + getUser.mockImplementation((_id, successCallback) => { + resolveUser = successCallback; + }); + + const { result } = renderContextHook({ 'metadata.namespacesOptIn.enabled': true }); + + expect(api.getMetadataAPI).not.toHaveBeenCalled(); + expect(result.current.enterpriseId).toBeUndefined(); + expect(result.current.isTemplateManagementEnabled).toBe(false); + + await act(async () => { + resolveUser({ enterprise: { id: enterpriseNumericId } }); + }); + + await waitFor(() => { + expect(getMetadataNamespaceMode).toHaveBeenCalledWith({ id: fileId }, enterpriseNumericId); + }); + }); +}); diff --git a/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts b/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts index 7400c8f6e7..9383be6b60 100644 --- a/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts +++ b/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts @@ -21,13 +21,14 @@ interface UseCurrentUserEnterpriseIdReturn { */ export default function useCurrentUserEnterpriseId( api: API, - file: BoxItem | null, + file: BoxItem | { id: string } | null, isEnabled: boolean = true, ): UseCurrentUserEnterpriseIdReturn { const [enterpriseNumericId, setEnterpriseNumericId] = useState(undefined); + const fileId = file?.id; useEffect(() => { - if (!isEnabled || !file?.id) { + if (!isEnabled || !fileId) { setEnterpriseNumericId(undefined); return undefined; } @@ -35,7 +36,7 @@ export default function useCurrentUserEnterpriseId( let cancelled = false; api.getUsersAPI(false).getUser( - file.id, + fileId, (user: User) => { if (!cancelled) { setEnterpriseNumericId(user?.enterprise?.id); @@ -56,7 +57,7 @@ export default function useCurrentUserEnterpriseId( return () => { cancelled = true; }; - }, [api, file?.id, isEnabled]); + }, [api, fileId, isEnabled]); const enterpriseId = enterpriseNumericId ? `${METADATA_SCOPE_ENTERPRISE}_${enterpriseNumericId}` : undefined; diff --git a/src/elements/content-sidebar/hooks/useMetadataNamespaceContext.ts b/src/elements/content-sidebar/hooks/useMetadataNamespaceContext.ts new file mode 100644 index 0000000000..0adc1d43cc --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceContext.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import API from '../../../api'; +import { METADATA_SCOPE_MODE_SCOPED } from '../../../constants'; +import { useFeatureEnabled } from '../../common/feature-checking'; +import useCurrentUserEnterpriseId from './useCurrentUserEnterpriseId'; +import useMetadataNamespaceMode, { type MetadataScopeMode } from './useMetadataNamespaceMode'; + +export interface MetadataNamespaceContext { + /** Enterprise root FQN (e.g. `enterprise_123`), or `undefined` while loading / unavailable. */ + enterpriseId: string | undefined; + /** Migration mode from enterprise configurations, or `null` while loading / unavailable. */ + metadataNamespaceMode: MetadataScopeMode | null; + /** True when mode is known and not SCOPED (template browser / management UI). */ + isTemplateManagementEnabled: boolean; + /** True while the enterprise-configurations request is in flight. */ + isLoading: boolean; +} + +/** + * Resolves enterprise root namespace + migration mode for the metadata sidebar. + * + * Uses `fileId` only (does not wait on the file GET from `useSidebarMetadataFetcher`) + * so `getMetadata` can receive the authoritative enterprise FQN in MIGRATION/FINAL. + * + * When `metadata.namespacesOptIn.enabled` is off, skips network calls and returns + * the legacy SCOPED-equivalent UI state (`mode: null`, management disabled). + */ +export default function useMetadataNamespaceContext(api: API, fileId: string): MetadataNamespaceContext { + const isNamespacesOptInEnabled = useFeatureEnabled('metadata.namespacesOptIn.enabled'); + const enterpriseFile = useMemo(() => (fileId ? { id: fileId } : null), [fileId]); + + const { enterpriseId, enterpriseNumericId } = useCurrentUserEnterpriseId( + api, + enterpriseFile, + isNamespacesOptInEnabled, + ); + const { mode: metadataNamespaceMode, isLoading } = useMetadataNamespaceMode( + enterpriseFile, + api, + enterpriseNumericId, + isNamespacesOptInEnabled, + ); + + const isTemplateManagementEnabled = !!metadataNamespaceMode && metadataNamespaceMode !== METADATA_SCOPE_MODE_SCOPED; + + return { + enterpriseId, + metadataNamespaceMode, + isTemplateManagementEnabled, + isLoading, + }; +} diff --git a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts index 288e8a15e7..d7b8d010aa 100644 --- a/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts @@ -37,16 +37,17 @@ interface UseMetadataNamespaceModeReturn { * !!mode && mode !== METADATA_SCOPE_MODE_SCOPED; */ export default function useMetadataNamespaceMode( - file: BoxItem, + file: BoxItem | { id: string } | null, api: API, enterpriseNumericId: string | undefined, isEnabled: boolean = false, ): UseMetadataNamespaceModeReturn { const [mode, setMode] = useState(null); const [isLoading, setIsLoading] = useState(false); + const fileId = file?.id; useEffect(() => { - if (!isEnabled || !file || !enterpriseNumericId) { + if (!isEnabled || !fileId || !enterpriseNumericId) { setMode(null); setIsLoading(false); return undefined; @@ -56,7 +57,7 @@ export default function useMetadataNamespaceMode( setIsLoading(true); const metadataAPI = api.getMetadataAPI(false); - metadataAPI.getMetadataNamespaceMode(file, enterpriseNumericId).then(resolvedMode => { + metadataAPI.getMetadataNamespaceMode({ id: fileId }, enterpriseNumericId).then(resolvedMode => { if (!isCancelled) { setMode(resolvedMode as MetadataScopeMode | null); setIsLoading(false); @@ -66,7 +67,7 @@ export default function useMetadataNamespaceMode( return () => { isCancelled = true; }; - }, [api, file, enterpriseNumericId, isEnabled]); + }, [api, fileId, enterpriseNumericId, isEnabled]); return { mode, isLoading }; } diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index a597ced8d4..da36abd04f 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -21,6 +21,7 @@ import { FIELD_IS_EXTERNALLY_OWNED, FIELD_PERMISSIONS_CAN_UPLOAD, FIELD_PERMISSIONS, + METADATA_SCOPE_MODE_SCOPED, SUCCESS_CODE_UPDATE_METADATA_TEMPLATE_INSTANCE, SUCCESS_CODE_DELETE_METADATA_TEMPLATE_INSTANCE, SUCCESS_CODE_CREATE_METADATA_TEMPLATE_INSTANCE, @@ -66,6 +67,14 @@ interface DataFetcher { templates: Array; } +/** Namespace migration context forwarded into Metadata.getMetadata options. */ +export type MetadataNamespaceFetchContext = { + /** Enterprise root namespace FQN from the current user (e.g. `enterprise_123`). */ + enterpriseFqn?: string; + /** Migration mode from enterprise_configurations (`SCOPED` | `MIGRATION` | `FINAL`). */ + metadataNamespaceMode?: string | null; +}; + function useSidebarMetadataFetcher( api: API, fileId: string, @@ -74,7 +83,9 @@ function useSidebarMetadataFetcher( isFeatureEnabled: ExternalProps['isFeatureEnabled'], isConfidenceScoreEnabled: boolean = false, isBoundingBoxEnabled: boolean = false, + namespaceContext: MetadataNamespaceFetchContext = {}, ): DataFetcher { + const { enterpriseFqn, metadataNamespaceMode } = namespaceContext; const [status, setStatus] = React.useState(STATUS.IDLE); const [file, setFile] = React.useState(null); const [templates, setTemplates] = React.useState(null); @@ -130,17 +141,23 @@ function useSidebarMetadataFetcher( fetchMetadataSuccessCallback, fetchMetadataErrorCallback, isFeatureEnabled, - { refreshCache: true }, + { + refreshCache: true, + ...(enterpriseFqn ? { enterpriseFqn } : {}), + ...(metadataNamespaceMode ? { metadataNamespaceMode } : {}), + }, true, isBoundingBoxOrConfidenceScoreReviewEnabled, ); }, [ api, + enterpriseFqn, fetchMetadataErrorCallback, fetchMetadataSuccessCallback, isFeatureEnabled, isBoundingBoxOrConfidenceScoreReviewEnabled, + metadataNamespaceMode, ], ); @@ -284,7 +301,8 @@ function useSidebarMetadataFetcher( } const templateInstance = templates.find( - template => template.templateKey === templateKey && (template.scope || template.namespace), + template => + template.templateKey === templateKey && (template.scope === scope || template.namespace === scope), ); const fields = templateInstance?.fields || []; @@ -332,6 +350,21 @@ function useSidebarMetadataFetcher( } }, [api, fetchFileErrorCallback, fetchFileSuccessCallback, fileId, status]); + // When namespace mode + enterprise FQN become available, refetch so getMetadata + // uses the non-SCOPED path with the authoritative enterprise root namespace. + const lastNamespaceFetchKey = React.useRef(null); + React.useEffect(() => { + if (!file || !enterpriseFqn || !metadataNamespaceMode || metadataNamespaceMode === METADATA_SCOPE_MODE_SCOPED) { + return; + } + const fetchKey = `${file.id}:${enterpriseFqn}:${metadataNamespaceMode}`; + if (lastNamespaceFetchKey.current === fetchKey) { + return; + } + lastNamespaceFetchKey.current = fetchKey; + fetchMetadata(file); + }, [enterpriseFqn, fetchMetadata, file, metadataNamespaceMode]); + const refetchMetadata = React.useCallback(() => { if (file) { fetchMetadata(file); From 2e804932729a1556a232ae459ab8543ec05a14de Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Mon, 3 Aug 2026 16:33:06 +0200 Subject: [PATCH 10/14] feat(metadata-sidebar): Fix naming --- src/api/Metadata.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 5e594fbd9b..34f6695f0f 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -88,7 +88,7 @@ type MetadataGetOptions = { }; class Metadata extends File { - _namespacesAPI: ?MetadataNamespaces; + namespacesAPI: ?MetadataNamespaces; /** * Lazy collaborator for namespace-migration HTTP (list/create/update + mode). @@ -96,10 +96,10 @@ class Metadata extends File { * @return {MetadataNamespaces} */ getNamespacesAPI(): MetadataNamespaces { - if (!this._namespacesAPI) { - this._namespacesAPI = new MetadataNamespaces(this); + if (!this.namespacesAPI) { + this.namespacesAPI = new MetadataNamespaces(this); } - return this._namespacesAPI; + return this.namespacesAPI; } /** @@ -523,8 +523,6 @@ class Metadata extends File { const url = instanceId ? this.getMetadataTemplateUrlForInstance(instanceId) : this.getMetadataTemplateUrlForScope(scope); - console.log('fetching templates for scope', scope, 'and instanceId', instanceId, 'url', url); - try { templates = await this.xhr.get({ url, From 137f9c11f8d61892e04997088b8d7d7b11a36cde Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Mon, 3 Aug 2026 16:35:36 +0200 Subject: [PATCH 11/14] feat(metadata-editor): Fix flow types --- src/api/MetadataNamespaces.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/api/MetadataNamespaces.js b/src/api/MetadataNamespaces.js index c91e87fb9c..24518c7890 100644 --- a/src/api/MetadataNamespaces.js +++ b/src/api/MetadataNamespaces.js @@ -20,6 +20,7 @@ import { import type { ElementsErrorCallback } from '../common/types/api'; import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; +import type Xhr from '../utils/Xhr'; import { resolveMetadataNamespaceMode } from './metadataNamespaceUtils'; // TODO(MDX-2136): remove this import when namespace API is deployed import { @@ -33,19 +34,17 @@ import { /** Minimal host surface MetadataNamespaces needs from Metadata. */ export type MetadataNamespaceHost = { + // Writable: MetadataNamespaces assigns error codes onto the host. errorCode: string, - getBaseApiUrl: () => string, - getCache: () => APICache, - getMetadataTemplateSchemaCacheKey: (templateKey: string, scope?: string) => string, - getMetadataTemplateSchemaUrl: (templateKey: string, scope?: string) => string, - getMetadataTemplateUrl: () => string, - getMetadataTemplateUrlForScope: (scope: string) => string, - isDestroyed: () => boolean, - xhr: { - get: (config: Object) => Promise, - post: (config: Object) => Promise, - put: (config: Object) => Promise, - }, + // Methods/+xhr are covariant so class instances (read-only methods) are assignable. + +getBaseApiUrl: () => string, + +getCache: () => APICache, + +getMetadataTemplateSchemaCacheKey: (templateKey: string, scope?: string) => string, + +getMetadataTemplateSchemaUrl: (templateKey: string, scope?: string) => string, + +getMetadataTemplateUrl: () => string, + +getMetadataTemplateUrlForScope: (scope: string) => string, + +isDestroyed: () => boolean, + +xhr: Xhr, }; export default class MetadataNamespaces { From 6427621d7553dd5fffdb21dc32ca6a9a2a91dbd3 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Mon, 3 Aug 2026 16:40:22 +0200 Subject: [PATCH 12/14] feat(metadata-sidebar): Change todos --- src/api/MetadataNamespaces.js | 12 ++++++------ src/api/metadataNamespaceMocks.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/MetadataNamespaces.js b/src/api/MetadataNamespaces.js index 24518c7890..b7893a7f7b 100644 --- a/src/api/MetadataNamespaces.js +++ b/src/api/MetadataNamespaces.js @@ -22,7 +22,7 @@ import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; import type Xhr from '../utils/Xhr'; import { resolveMetadataNamespaceMode } from './metadataNamespaceUtils'; -// TODO(MDX-2136): remove this import when namespace API is deployed +// TODO: remove this import when namespace API is deployed import { IS_NAMESPACE_API_MOCKED, mockListNamespaces, @@ -70,7 +70,7 @@ export default class MetadataNamespaces { namespaceFqn: string, params: { limit: number, marker?: string }, ): Promise<{ entries: Array, next_marker?: string }> { - // TODO(MDX-2136): remove next line when namespace API is deployed + // TODO: remove next line when namespace API is deployed if (IS_NAMESPACE_API_MOCKED) return mockListNamespaces(file, namespaceFqn, params); const { id }: BoxItem = file; @@ -95,7 +95,7 @@ export default class MetadataNamespaces { namespaceFqn: string, params: { limit: number, marker?: string }, ): Promise<{ entries: Array, next_marker?: string }> { - // TODO(MDX-2136): remove next line when namespace API is deployed + // TODO: remove next line when namespace API is deployed if (IS_NAMESPACE_API_MOCKED) return mockListTemplatesForNamespace(file, namespaceFqn, params); const { id }: BoxItem = file; @@ -144,7 +144,7 @@ export default class MetadataNamespaces { successCallback: Function, errorCallback: ElementsErrorCallback, ): Promise { - // TODO(MDX-2136): remove next two lines when namespace API is deployed + // TODO: remove next two lines when namespace API is deployed if (IS_NAMESPACE_API_MOCKED) { mockCreateMetadataTemplate(file, body, successCallback); return; @@ -174,7 +174,7 @@ export default class MetadataNamespaces { successCallback: Function, errorCallback: ElementsErrorCallback, ): Promise { - // TODO(MDX-2136): remove next two lines when namespace API is deployed + // TODO: remove next two lines when namespace API is deployed if (IS_NAMESPACE_API_MOCKED) { mockUpdateMetadataTemplate(file, namespaceFqn, templateKey, patchItems, successCallback); return; @@ -203,7 +203,7 @@ export default class MetadataNamespaces { * Fetches a template schema in the shape expected by MetadataTemplateEditor. */ async getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { - // TODO(MDX-2136): remove the mock block when namespace API is deployed. + // TODO: remove the mock block when namespace API is deployed. if (IS_NAMESPACE_API_MOCKED) { const mockResult = mockGetTemplateSchemaForEditor(namespaceFqn, templateKey); if (mockResult) return mockResult; diff --git a/src/api/metadataNamespaceMocks.js b/src/api/metadataNamespaceMocks.js index d67699ba61..06101a7224 100644 --- a/src/api/metadataNamespaceMocks.js +++ b/src/api/metadataNamespaceMocks.js @@ -1,5 +1,5 @@ // @flow -// ─── TODO(MDX-2136): Namespace API mocks ───────────────────────────────────── +// ─── TODO: Namespace API mocks (remove when namespace API is deployed) ──────── // Temporary stand-ins for the namespace-related Metadata APIs. // // To remove all mocks when the real API is deployed: From 47c2478cdef9e4578725ad5fb17e20e499fc8c39 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 4 Aug 2026 11:43:00 +0200 Subject: [PATCH 13/14] feat(metadata-sidebar): Adress comments --- package.json | 2 +- .../MetadataSidebarRedesign.tsx | 6 +- .../MetadataTemplateDropdown.tsx | 4 +- .../metadataTemplateIdentity.test.ts | 84 +++++++++++++++++++ .../hooks/useMetadataTemplateEventService.ts | 8 +- .../hooks/useMetadataTemplateItemsService.ts | 8 +- .../hooks/useSidebarMetadataFetcher.ts | 6 +- .../convertTemplateToTemplateInstance.ts | 8 +- .../utils/metadataTemplateIdentity.ts | 45 ++++++++++ yarn.lock | 8 +- 10 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts create mode 100644 src/elements/content-sidebar/utils/metadataTemplateIdentity.ts diff --git a/package.json b/package.json index f4462549e8..81bfdfe8e1 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "@box/metadata-filter": "^1.80.23", "@box/metadata-taxonomy-picker": "^3.1.25", "@box/metadata-template-browser": "^2.1.7", - "@box/metadata-template-editor": "^2.2.16", + "@box/metadata-template-editor": "^2.3.0", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.8", diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 378d6bb430..7197906d2c 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -48,6 +48,7 @@ import MetadataInstanceEditor from './MetadataInstanceEditor'; import MetadataTemplateDropdown from './MetadataTemplateDropdown'; import { convertTemplateToTemplateInstance } from './utils/convertTemplateToTemplateInstance'; import { isExtensionSupportedForMetadataSuggestions } from './utils/isExtensionSupportedForMetadataSuggestions'; +import { isSameMetadataTemplate } from './utils/metadataTemplateIdentity'; import { createTaxonomyItemsService, metadataTaxonomyFetcher, @@ -238,10 +239,7 @@ function MetadataSidebarRedesign({ useEffect(() => { // disable only pre-existing template instances from dropdown if not editing or editing pre-exiting one const isEditingTemplateAlreadyExisting = - editingTemplate && - templateInstances.some( - t => t.templateKey === editingTemplate.templateKey && t.scope === editingTemplate.scope, - ); + editingTemplate && templateInstances.some(t => isSameMetadataTemplate(t, editingTemplate)); if (!editingTemplate || isEditingTemplateAlreadyExisting) { setAppliedTemplateInstances(templateInstances); diff --git a/src/elements/content-sidebar/MetadataTemplateDropdown.tsx b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx index 222632ad50..feee407d63 100644 --- a/src/elements/content-sidebar/MetadataTemplateDropdown.tsx +++ b/src/elements/content-sidebar/MetadataTemplateDropdown.tsx @@ -14,6 +14,7 @@ import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-e import type { ItemsService } from '@box/metadata-template-browser'; import useMetadataTemplateEventService from './hooks/useMetadataTemplateEventService'; +import { getMetadataTemplateNamespaceFqn } from './utils/metadataTemplateIdentity'; export interface MetadataTemplateDropdownProps { templates: EditorMetadataTemplate[]; @@ -62,8 +63,7 @@ export default function MetadataTemplateDropdown({ // Primary: exact id match against already-loaded editor templates. const template = templates.find(t => t.id === templateId); if (template?.templateKey) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const namespaceFqn = template.scope ?? (template as any).namespace; + const namespaceFqn = getMetadataTemplateNamespaceFqn(template); if (namespaceFqn) { onEditTemplate({ namespaceFqn, templateKey: template.templateKey }); return; diff --git a/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts new file mode 100644 index 0000000000..ee96c8fce6 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts @@ -0,0 +1,84 @@ +import { getMetadataTemplateNamespaceFqn, isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; + +describe('metadataTemplateIdentity', () => { + describe('getMetadataTemplateNamespaceFqn', () => { + test('prefers scope when present', () => { + expect( + getMetadataTemplateNamespaceFqn({ + templateKey: 'contract', + scope: 'enterprise_123', + namespace: 'enterprise_123.legal', + }), + ).toBe('enterprise_123'); + }); + + test('falls back to namespace when scope is absent', () => { + expect( + getMetadataTemplateNamespaceFqn({ + templateKey: 'contract', + namespace: 'enterprise_123.legal', + }), + ).toBe('enterprise_123.legal'); + }); + }); + + describe('isSameMetadataTemplate', () => { + test('matches by templateKey and scope when both define scope', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', scope: 'enterprise_123' }, + { templateKey: 'contract', scope: 'enterprise_123' }, + ), + ).toBe(true); + }); + + test('does not match different scopes with the same templateKey', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', scope: 'enterprise_123' }, + { templateKey: 'contract', scope: 'enterprise_999' }, + ), + ).toBe(false); + }); + + test('matches when scopes differ but namespace FQNs agree (browser scope vs editor namespace)', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', scope: 'enterprise_123', namespace: 'enterprise_123.legal' }, + { templateKey: 'contract', scope: 'enterprise_123.legal' }, + ), + ).toBe(true); + }); + + test('matches namespace-only templates by namespace FQN', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', namespace: 'enterprise_123.legal' }, + { templateKey: 'contract', namespace: 'enterprise_123.legal' }, + ), + ).toBe(true); + }); + + test('does not collapse distinct child-namespace templates that share a templateKey', () => { + // Regression for review feedback: undefined === undefined must not match. + expect( + isSameMetadataTemplate( + { templateKey: 'contract', namespace: 'enterprise_123.legal' }, + { templateKey: 'contract', namespace: 'enterprise_123.hr' }, + ), + ).toBe(false); + expect( + isSameMetadataTemplate({ templateKey: 'contract' }, { templateKey: 'contract' }), + ).toBe(false); + }); + + test('matches when one side stores the FQN in scope and the other in namespace', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', namespace: 'enterprise_123.legal' }, + { templateKey: 'contract', scope: 'enterprise_123.legal' }, + ), + ).toBe(true); + }); + }); +}); diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts index 3d37bcf5c1..6cbf5dc6e5 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import type { EventService, MetadataTemplate as BrowserMetadataTemplate } from '@box/metadata-template-browser'; import type { MetadataTemplate as EditorMetadataTemplate } from '@box/metadata-editor'; +import { isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; interface UseMetadataTemplateEventServiceArgs { /** @@ -50,12 +51,7 @@ export default function useMetadataTemplateEventService({ // returns a different id shape than the editor list (e.g. during mock dev). const editorTemplate = templates.find(t => t.id === browserTemplate.id) ?? - templates.find( - t => - t.templateKey === browserTemplate.templateKey && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (t.scope === browserTemplate.scope || (t as any).namespace === browserTemplate.scope), - ); + templates.find(t => isSameMetadataTemplate(t, browserTemplate)); if (editorTemplate) { onSelect(editorTemplate); } diff --git a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts index 3b7704b59a..893ebcad48 100644 --- a/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -13,6 +13,7 @@ import API from '../../../api'; import { METADATA_TEMPLATE_PROPERTIES } from '../../../constants'; import messages from '../../../features/metadata-instance-editor/messages'; import type { BoxItem } from '../../../common/types/core'; +import { isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; function resolveDisplayName(template: EditorMetadataTemplate, customMetadataName: string): string { if (template.templateKey === METADATA_TEMPLATE_PROPERTIES) { @@ -89,11 +90,8 @@ export default function useMetadataTemplateItemsService( // handleEditTemplateById / onTemplateSelect resolve correctly. // Falls back to the raw API id when there is no matching editor template // (e.g. child-namespace-only templates not yet in the editor list). - const editorMatch = templates.find( - et => - et.templateKey === templateKey && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (et.scope === templateScope || (et as any).namespace === templateScope), + const editorMatch = templates.find(et => + isSameMetadataTemplate(et, { templateKey, scope: templateScope }), ); return { id: editorMatch?.id ?? (t.id as string), diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index da36abd04f..c1adbc25d1 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -32,6 +32,7 @@ import messages from '../../common/messages'; import { type BoxItem } from '../../../common/types/core'; import { type ErrorContextProps, type ExternalProps, type SuccessContextProps } from '../MetadataSidebarRedesign'; import { type AiExtractStructured } from '../../../api/schemas/AiExtractStructured'; +import { isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; export enum STATUS { IDLE = 'idle', @@ -300,9 +301,8 @@ function useSidebarMetadataFetcher( return []; } - const templateInstance = templates.find( - template => - template.templateKey === templateKey && (template.scope === scope || template.namespace === scope), + const templateInstance = templates.find(template => + isSameMetadataTemplate(template, { templateKey, scope }), ); const fields = templateInstance?.fields || []; diff --git a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts index 3bb70b36eb..8916832c34 100644 --- a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts +++ b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts @@ -1,10 +1,15 @@ import { type MetadataTemplate, type MetadataTemplateInstance } from '@box/metadata-editor'; import { type BoxItem } from '../../../common/types/core'; +import { type MetadataTemplateIdentity } from './metadataTemplateIdentity'; export const convertTemplateToTemplateInstance = ( file: BoxItem | null, template: MetadataTemplate, ): MetadataTemplateInstance => { + // `namespace` is present at runtime in MIGRATION/FINAL modes but not yet on + // the `@box/metadata-editor` MetadataTemplate type. + const namespace = (template as MetadataTemplate & MetadataTemplateIdentity).namespace; + return { canEdit: !!file.permissions.can_upload, displayName: template.displayName, @@ -12,7 +17,8 @@ export const convertTemplateToTemplateInstance = ( id: template.id, fields: template.fields, scope: template.scope, + ...(namespace != null && { namespace }), templateKey: template.templateKey, type: template.type, - }; + } as MetadataTemplateInstance; }; diff --git a/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts new file mode 100644 index 0000000000..c6b3f72001 --- /dev/null +++ b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts @@ -0,0 +1,45 @@ +/** + * Runtime shape for metadata templates/instances during the scope → namespace + * migration. `@box/metadata-editor` types still only declare `scope`, but the + * API (and BUIE's Flow types) may populate `namespace` instead — or in addition — + * in MIGRATION/FINAL modes. + */ +export type MetadataTemplateIdentity = { + templateKey: string; + scope?: string; + namespace?: string; +}; + +/** Read scope or namespace FQN from a template/instance that may only have one. */ +export function getMetadataTemplateNamespaceFqn(template: MetadataTemplateIdentity): string | undefined { + return template.scope ?? template.namespace; +} + +/** + * Whether two metadata templates/instances refer to the same template. + * + * Mirrors `Metadata.getTemplateForInstance` matching: + * - Prefer `scope` when both sides define it (SCOPED / enterprise-scoped MIGRATION) + * - Otherwise compare namespace FQNs (including when one side stores the FQN in `scope` + * and the other in `namespace`, as the template browser does) + * - Never treat two missing scopes as a match — that would collapse distinct + * child-namespace templates that share a `templateKey` + * (e.g. `enterprise_123.legal.contract` vs `enterprise_123.hr.contract`). + */ +export function isSameMetadataTemplate(a: MetadataTemplateIdentity, b: MetadataTemplateIdentity): boolean { + if (a.templateKey !== b.templateKey) { + return false; + } + // Fast path: both sides still carry a legacy scope and they agree. + if (a.scope != null && b.scope != null && a.scope === b.scope) { + return true; + } + // Otherwise compare resolved FQNs. Prefer `namespace` when present so a + // browser-shaped template (FQN in `scope`) can match an editor-shaped + // template that only has `namespace` (or has both). Two missing FQNs must + // not match — that would collapse distinct child-namespace templates that + // share a templateKey. + const aFqn = a.namespace ?? a.scope; + const bFqn = b.namespace ?? b.scope; + return aFqn != null && bFqn != null && aFqn === bFqn; +} diff --git a/yarn.lock b/yarn.lock index bb8edf8dc9..3c3dde8459 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1197,10 +1197,10 @@ resolved "https://registry.yarnpkg.com/@box/metadata-template-browser/-/metadata-template-browser-2.1.7.tgz#f31de745e40004805a230cd624912e1c5cf9547f" integrity sha512-MzuFyzVInAR0ZqT4g3wro47DmirT39YaYIsxRborZtL1TpaQpgxZoqCWQ+/pgtuGoGHyoP+kpueO8APeC+XCGg== -"@box/metadata-template-editor@^2.2.16": - version "2.2.16" - resolved "https://registry.yarnpkg.com/@box/metadata-template-editor/-/metadata-template-editor-2.2.16.tgz#3eb232280235023cde5e885d876f990365e399c7" - integrity sha512-L2Qt1jLzMlQEQ1dAZHPsznDTWjeHvM5O0XfHtaXlah3F5FvVP7gdCsiAkEGc4dJaJw3f9pyV2HeLCZnqQMed0g== +"@box/metadata-template-editor@^2.3.0": + version "2.3.17" + resolved "https://registry.yarnpkg.com/@box/metadata-template-editor/-/metadata-template-editor-2.3.17.tgz#cd99c63748dd9e8942bbd6e2e1fd928a091e4362" + integrity sha512-heAXhnnACJ4uT9pENFOQDdwE2LtGeepmK6APRxhU2ZoIShlVWmocSMkzTnJKav2WQ6RE1ZclLXeDVeKBi0I8jg== "@box/metadata-view@^1.53.26": version "1.53.26" From 50097541d9f2f5d3945a95ed7d2f90bc1e48138e Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Tue, 4 Aug 2026 13:12:54 +0200 Subject: [PATCH 14/14] fix(metadata-sidebar): fix typing failures --- .../__tests__/metadataTemplateIdentity.test.ts | 12 ++++++++++++ .../utils/convertTemplateToTemplateInstance.ts | 2 +- .../utils/metadataTemplateIdentity.ts | 5 +++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts index ee96c8fce6..cbce1e10fc 100644 --- a/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts +++ b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts @@ -72,6 +72,18 @@ describe('metadataTemplateIdentity', () => { ).toBe(false); }); + test('does not match when either templateKey is missing', () => { + expect( + isSameMetadataTemplate( + { templateKey: 'contract', scope: 'enterprise_123' }, + { scope: 'enterprise_123' }, + ), + ).toBe(false); + expect( + isSameMetadataTemplate({ scope: 'enterprise_123' }, { templateKey: 'contract', scope: 'enterprise_123' }), + ).toBe(false); + }); + test('matches when one side stores the FQN in scope and the other in namespace', () => { expect( isSameMetadataTemplate( diff --git a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts index 8916832c34..024040fa76 100644 --- a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts +++ b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts @@ -8,7 +8,7 @@ export const convertTemplateToTemplateInstance = ( ): MetadataTemplateInstance => { // `namespace` is present at runtime in MIGRATION/FINAL modes but not yet on // the `@box/metadata-editor` MetadataTemplate type. - const namespace = (template as MetadataTemplate & MetadataTemplateIdentity).namespace; + const { namespace } = template as MetadataTemplate & MetadataTemplateIdentity; return { canEdit: !!file.permissions.can_upload, diff --git a/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts index c6b3f72001..7035e3156a 100644 --- a/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts +++ b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts @@ -5,7 +5,8 @@ * in MIGRATION/FINAL modes. */ export type MetadataTemplateIdentity = { - templateKey: string; + /** Optional on `@box/metadata-template-browser` MetadataTemplate; required at runtime for a match. */ + templateKey?: string; scope?: string; namespace?: string; }; @@ -27,7 +28,7 @@ export function getMetadataTemplateNamespaceFqn(template: MetadataTemplateIdenti * (e.g. `enterprise_123.legal.contract` vs `enterprise_123.hr.contract`). */ export function isSameMetadataTemplate(a: MetadataTemplateIdentity, b: MetadataTemplateIdentity): boolean { - if (a.templateKey !== b.templateKey) { + if (a.templateKey == null || b.templateKey == null || a.templateKey !== b.templateKey) { return false; } // Fast path: both sides still carry a legacy scope and they agree.