From 064bd17ee33c2c122ec60c77519cb63e33426b09 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 15 Jul 2026 11:40:26 +0200 Subject: [PATCH 1/3] fix: support mid-migration metadata templates --- package.json | 12 +- 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, 1470 insertions(+), 113 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 9fa89df588..acf73a0cee 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,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", @@ -292,7 +295,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.2.1", @@ -319,6 +323,9 @@ "@box/unified-share-modal": "^2.15.16", "@box/uploads-manager": "^1.17.2", "@box/user-selector": "^2.1.24", + "@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.32.0", @@ -362,7 +369,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", 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 e70ebc5ba5..6452950db5 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 6e71ca03ec..2e3072c858 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1524,6 +1524,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" @@ -15984,6 +16015,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" @@ -18671,6 +18707,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" @@ -18764,6 +18805,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" @@ -18950,7 +18996,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== @@ -19998,6 +20044,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 d38c323796f3843e6a47846b9653bc70410c133e Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Wed, 15 Jul 2026 15:31:43 +0200 Subject: [PATCH 2/3] 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 7b3fcd858c1ac9d4211784f5f6990878a120c9a7 Mon Sep 17 00:00:00 2001 From: Jakub Kida Date: Thu, 16 Jul 2026 14:32:26 +0200 Subject: [PATCH 3/3] 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 acf73a0cee..21f786073b 100644 --- a/package.json +++ b/package.json @@ -138,10 +138,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.1.23", @@ -315,6 +316,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.1.23", diff --git a/yarn.lock b/yarn.lock index 2e3072c858..2260094783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1178,10 +1178,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" @@ -1198,6 +1198,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"