diff --git a/package.json b/package.json index 8307a36521..81bfdfe8e1 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": { @@ -138,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.3.0", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.8", @@ -154,6 +159,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 +301,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", @@ -312,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", @@ -320,6 +330,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 +376,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 +389,8 @@ "tar": "^7.5.19" }, "msw": { - "workerDirectory": [".storybook/public"] + "workerDirectory": [ + ".storybook/public" + ] } -} +} \ No newline at end of file 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/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)/)', ], }; 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..34f6695f0f 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -30,8 +30,10 @@ import { AI_ACCEPTED_PROCESS, AI_EXTRACTED_PROCESS, HEADER_CONTENT_TYPE, + METADATA_SCOPE_MODE_SCOPED, METADATA_SCOPE_ENTERPRISE, METADATA_SCOPE_GLOBAL, + METADATA_NAMESPACE_GLOBAL, METADATA_TEMPLATE_FETCH_LIMIT, METADATA_TEMPLATE_PROPERTIES, METADATA_TEMPLATE_CLASSIFICATION, @@ -54,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, @@ -67,8 +69,39 @@ import type { } from '../common/types/metadata'; import type { BoxItem } from '../common/types/core'; import type APICache from '../utils/Cache'; +import MetadataNamespaces from './MetadataNamespaces'; +import { + 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 * @@ -103,10 +136,28 @@ 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}`; + } + + /** + * 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 { + return resolveScopeOrNamespace(this.metadataNamespaceMode, scope, namespace); } /** @@ -213,13 +264,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 +290,73 @@ class Metadata extends File { return `${this.getMetadataTemplateUrl()}/${scope}`; } + /** @see MetadataNamespaces.getMetadataNamespacesUrl */ + getMetadataNamespacesUrl(namespaceFqn: string): string { + return this.getNamespacesAPI().getMetadataNamespacesUrl(namespaceFqn); + } + + /** @see MetadataNamespaces.listNamespaces */ + listNamespaces( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + return this.getNamespacesAPI().listNamespaces(file, namespaceFqn, params); + } + + /** @see MetadataNamespaces.listTemplatesForNamespace */ + listTemplatesForNamespace( + file: BoxItem, + namespaceFqn: string, + params: { limit: number, marker?: string }, + ): Promise<{ entries: Array, next_marker?: string }> { + return this.getNamespacesAPI().listTemplatesForNamespace(file, namespaceFqn, params); + } + + /** @see MetadataNamespaces.getEnterpriseConfigurationsUrl */ + getEnterpriseConfigurationsUrl(enterpriseNumericId: string): string { + return this.getNamespacesAPI().getEnterpriseConfigurationsUrl(enterpriseNumericId); + } + + /** @see MetadataNamespaces.getMetadataNamespaceMode */ + getMetadataNamespaceMode(file: BoxItem, enterpriseNumericId: string): Promise { + return this.getNamespacesAPI().getMetadataNamespaceMode(file, enterpriseNumericId); + } + + /** @see MetadataNamespaces.createMetadataTemplate */ + createMetadataTemplate( + file: BoxItem, + body: Object, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + return this.getNamespacesAPI().createMetadataTemplate(file, body, successCallback, errorCallback); + } + + /** @see MetadataNamespaces.updateMetadataTemplate */ + updateMetadataTemplate( + file: BoxItem, + namespaceFqn: string, + templateKey: string, + patchItems: Array, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + return this.getNamespacesAPI().updateMetadataTemplate( + file, + namespaceFqn, + templateKey, + patchItems, + successCallback, + errorCallback, + ); + } + + /** @see MetadataNamespaces.getTemplateSchemaForEditor */ + getTemplateSchemaForEditor(namespaceFqn: string, templateKey: string): Promise { + return this.getNamespacesAPI().getTemplateSchemaForEditor(namespaceFqn, templateKey); + } + /** * Returns the custom properties template * @@ -241,6 +366,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: [], @@ -397,7 +523,6 @@ class Metadata extends File { const url = instanceId ? this.getMetadataTemplateUrlForInstance(instanceId) : this.getMetadataTemplateUrlForScope(scope); - try { templates = await this.xhr.get({ url, @@ -423,25 +548,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); + const key = this.getMetadataTemplateSchemaCacheKey(templateKey, scope); - // 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; @@ -557,32 +691,63 @@ 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; const scope = instance.$scope; - const template = templates.find(t => t.templateKey === templateKey && t.scope === scope); - - // Enterprise scopes are always enterprise_XXXXX - 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); - // The API always returns an array of at most one item - const crossEnterpriseTemplate = crossEnterpriseTemplates[0]; - return { template: crossEnterpriseTemplate, isExternallyOwned: true }; + const namespace = instance.$namespace; + + // Primary match: by scope (SCOPED mode; also works for enterprise-scoped + // instances in MIGRATION mode where $scope is still populated). + // Only run when scope is defined so namespace-only instances (undefined + // $scope) do not incorrectly match templates that also lack scope. + let template = + scope != null ? templates.find(t => t.templateKey === templateKey && t.scope === scope) : undefined; + + // Fallback match: by namespace for namespace-only instances in + // MIGRATION/FINAL mode where $scope is absent. + if (!template && namespace) { + template = templates.find(t => t.templateKey === templateKey && t.namespace === namespace); + } + + 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; } @@ -605,6 +770,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( @@ -616,7 +782,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)); } @@ -696,6 +862,7 @@ class Metadata extends File { id: template.id, fields, scope: template.scope, + namespace: template.namespace, templateKey: template.templateKey, type: instance.$type, }; @@ -720,6 +887,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( @@ -732,7 +900,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( @@ -750,6 +918,57 @@ class Metadata extends File { return templateInstances; } + /** @see metadataNamespaceUtils.getEnterpriseNamespaceFromInstances */ + getEnterpriseNamespaceFromInstances(instances: Array): string | null { + return resolveEnterpriseNamespaceFromInstances(instances); + } + + /** + * SCOPED mode: fetch instances + global + enterprise templates in parallel. + */ + 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 }; + } + /** * API for getting metadata editors * @@ -771,7 +990,7 @@ class Metadata extends File { }) => void, errorCallback: ElementsErrorCallback, hasMetadataFeature: boolean, - options: RequestOptions = {}, + options: MetadataGetOptions = {}, isMetadataRedesign: boolean = false, isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise { @@ -805,14 +1024,32 @@ 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([]), - ]); + + // 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( @@ -823,6 +1060,7 @@ class Metadata extends File { globalTemplates, !!permissions.can_upload, isBoundingBoxOrConfidenceScoreReviewEnabled, + viewerEnterpriseFqn, ) : []; const editors = !isMetadataRedesign @@ -833,6 +1071,7 @@ class Metadata extends File { enterpriseTemplates, globalTemplates, !!permissions.can_upload, + viewerEnterpriseFqn, ) : []; @@ -900,7 +1139,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 +1191,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 +1253,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 +1374,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 +1437,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 +1449,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 +1502,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 +1576,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 +1622,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 +1640,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 +1650,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/MetadataNamespaces.js b/src/api/MetadataNamespaces.js new file mode 100644 index 0000000000..b7893a7f7b --- /dev/null +++ b/src/api/MetadataNamespaces.js @@ -0,0 +1,226 @@ +/** + * @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 type Xhr from '../utils/Xhr'; +import { resolveMetadataNamespaceMode } from './metadataNamespaceUtils'; +// TODO: 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 = { + // Writable: MetadataNamespaces assigns error codes onto the host. + errorCode: string, + // 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 { + 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: 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: 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: 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: 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: 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 78e1608895..8f336707e2 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: [], @@ -757,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'; @@ -769,6 +792,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()', () => { @@ -1023,6 +1065,73 @@ 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); + }); + + 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()', () => { @@ -1316,6 +1425,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1331,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', @@ -1373,6 +1527,7 @@ describe('api/Metadata', () => { 'global', true, false, + undefined, ); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); expect(metadata.successHandler).toHaveBeenCalledWith({ @@ -1422,6 +1577,7 @@ describe('api/Metadata', () => { 'global', true, true, + undefined, ); }); @@ -1466,6 +1622,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1524,6 +1681,7 @@ describe('api/Metadata', () => { 'enterprise', 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', 'enterprise', true, true); @@ -1581,6 +1739,7 @@ describe('api/Metadata', () => { [], 'global', true, + undefined, ); expect(metadata.getTemplateInstances).not.toHaveBeenCalled(); expect(metadata.getUserAddableTemplates).toHaveBeenCalledWith('custom', [], false, true); @@ -1676,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 new file mode 100644 index 0000000000..06101a7224 --- /dev/null +++ b/src/api/metadataNamespaceMocks.js @@ -0,0 +1,222 @@ +// @flow +// ─── 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: +// 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(updated); + return; + } + 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/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/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..7197906d2c 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'; @@ -41,8 +45,10 @@ 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 { isSameMetadataTemplate } from './utils/metadataTemplateIdentity'; import { createTaxonomyItemsService, metadataTaxonomyFetcher, @@ -52,6 +58,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 useMetadataNamespaceContext from './hooks/useMetadataNamespaceContext'; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}_${EVENT_JS_READY}`; @@ -125,11 +134,17 @@ function MetadataSidebarRedesign({ const isDeleteConfirmationModalCheckboxEnabled: boolean = useFeatureEnabled( 'metadata.deleteConfirmationModalCheckbox.enabled', ); + const isConfidenceScoreReviewEnabled: boolean = useFeatureEnabled('metadata.confidenceScore.enabled'); const isBoundingBoxEnabled = useFeatureEnabled('metadata.boundingBox.enabled'); const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundingBoxEnabled || isConfidenceScoreReviewEnabled; + const { enterpriseId, metadataNamespaceMode, isTemplateManagementEnabled } = useMetadataNamespaceContext( + api, + fileId, + ); + const { clearExtractError, extractSuggestions, @@ -137,6 +152,7 @@ function MetadataSidebarRedesign({ handleCreateMetadataInstance, handleDeleteMetadataInstance, handleUpdateMetadataInstance, + refetchMetadata, templates, extractErrorCode, errorMessage, @@ -150,6 +166,10 @@ function MetadataSidebarRedesign({ isFeatureEnabled, isConfidenceScoreReviewEnabled, isBoundingBoxEnabled, + { + enterpriseFqn: enterpriseId, + metadataNamespaceMode, + }, ); const isSessionInitiated = useRef(false); @@ -163,6 +183,18 @@ 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); + + // 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, @@ -207,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); @@ -221,6 +250,7 @@ function MetadataSidebarRedesign({ const handleTemplateSelect = (selectedTemplate: MetadataTemplate) => { clearExtractError(); + setIsDropdownOpen(false); if (editingTemplate) { setPendingTemplateToEdit(convertTemplateToTemplateInstance(file, selectedTemplate)); @@ -231,6 +261,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 +402,18 @@ function MetadataSidebarRedesign({ const canEdit = !!file?.permissions?.can_upload; const metadataDropdown = canEdit && isSuccess && templates && ( - ); @@ -366,79 +477,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..feee407d63 --- /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'; +import { getMetadataTemplateNamespaceFqn } from './utils/metadataTemplateIdentity'; + +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) { + const namespaceFqn = getMetadataTemplateNamespaceFqn(template); + 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-taxonomyPicker.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx index 90ae169c38..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,12 +106,20 @@ 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(), 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 2e8a9b7b1f..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(), @@ -155,6 +168,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); }); @@ -181,6 +195,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -196,6 +211,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(), @@ -203,6 +240,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, @@ -223,6 +261,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, @@ -243,6 +282,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), handleUpdateMetadataInstance: jest.fn(), + refetchMetadata: jest.fn(), templateInstances: [], templates: mockTemplates, errorMessage: null, @@ -285,6 +325,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -310,6 +351,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 +374,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.LOADING, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -378,6 +421,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -402,6 +446,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -428,6 +473,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent({}, { 'metadata.deleteConfirmationModalCheckbox.enabled': true }); @@ -458,6 +504,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -486,6 +533,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); renderComponent(); @@ -507,6 +555,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); const filteredTemplateIds = [mockVisibleTemplateInstance.id]; @@ -531,6 +580,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); const filteredTemplateIds = ['non-existing-template-id']; @@ -587,6 +637,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), // isFeatureEnabled true, // isConfidenceScoreReviewEnabled false, // isBoundingBoxEnabled + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -601,6 +652,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), false, false, + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -615,6 +667,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), // isFeatureEnabled false, // isConfidenceScoreReviewEnabled true, // isBoundingBoxEnabled + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -629,6 +682,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), false, false, + { enterpriseFqn: undefined, metadataNamespaceMode: null }, ); }); @@ -671,6 +725,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { status: STATUS.SUCCESS, file: mockFile, extractErrorCode: null, + refetchMetadata: jest.fn(), }); }; 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..cbce1e10fc --- /dev/null +++ b/src/elements/content-sidebar/__tests__/metadataTemplateIdentity.test.ts @@ -0,0 +1,96 @@ +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('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( + { templateKey: 'contract', namespace: 'enterprise_123.legal' }, + { templateKey: 'contract', scope: 'enterprise_123.legal' }, + ), + ).toBe(true); + }); + }); +}); 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__/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/__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..9383be6b60 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useCurrentUserEnterpriseId.ts @@ -0,0 +1,65 @@ +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 | { id: string } | null, + isEnabled: boolean = true, +): UseCurrentUserEnterpriseIdReturn { + const [enterpriseNumericId, setEnterpriseNumericId] = useState(undefined); + const fileId = file?.id; + + useEffect(() => { + if (!isEnabled || !fileId) { + setEnterpriseNumericId(undefined); + return undefined; + } + + let cancelled = false; + + api.getUsersAPI(false).getUser( + fileId, + (user: User) => { + if (!cancelled) { + setEnterpriseNumericId(user?.enterprise?.id); + } + }, + () => { + if (!cancelled) { + setEnterpriseNumericId(undefined); + } + }, + { + params: { + fields: FIELD_ENTERPRISE, + }, + }, + ); + + return () => { + cancelled = true; + }; + }, [api, fileId, isEnabled]); + + const enterpriseId = enterpriseNumericId ? `${METADATA_SCOPE_ENTERPRISE}_${enterpriseNumericId}` : undefined; + + return { enterpriseId, enterpriseNumericId }; +} 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 new file mode 100644 index 0000000000..d7b8d010aa --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataNamespaceMode.ts @@ -0,0 +1,73 @@ +import { useEffect, 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 (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. + * 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 | { 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 || !fileId || !enterpriseNumericId) { + setMode(null); + setIsLoading(false); + return undefined; + } + + let isCancelled = false; + setIsLoading(true); + + const metadataAPI = api.getMetadataAPI(false); + metadataAPI.getMetadataNamespaceMode({ id: fileId }, enterpriseNumericId).then(resolvedMode => { + if (!isCancelled) { + setMode(resolvedMode as MetadataScopeMode | null); + setIsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [api, fileId, 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..6cbf5dc6e5 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateEventService.ts @@ -0,0 +1,64 @@ +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 { + /** + * 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 => isSameMetadataTemplate(t, browserTemplate)); + 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..893ebcad48 --- /dev/null +++ b/src/elements/content-sidebar/hooks/useMetadataTemplateItemsService.ts @@ -0,0 +1,128 @@ +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'; +import { isSameMetadataTemplate } from '../utils/metadataTemplateIdentity'; + +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 (current user 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 => + isSameMetadataTemplate(et, { templateKey, scope: 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..c1adbc25d1 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, @@ -31,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', @@ -59,11 +61,21 @@ interface DataFetcher { JSONPatch: Array, successCallback: () => void, ) => Promise; + /** Re-fetches metadata (templates + instances) using the current file. */ + refetchMetadata: () => void; status: STATUS; templateInstances: Array; 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, @@ -72,7 +84,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); @@ -128,17 +142,23 @@ function useSidebarMetadataFetcher( fetchMetadataSuccessCallback, fetchMetadataErrorCallback, isFeatureEnabled, - { refreshCache: true }, + { + refreshCache: true, + ...(enterpriseFqn ? { enterpriseFqn } : {}), + ...(metadataNamespaceMode ? { metadataNamespaceMode } : {}), + }, true, isBoundingBoxOrConfidenceScoreReviewEnabled, ); }, [ api, + enterpriseFqn, fetchMetadataErrorCallback, fetchMetadataSuccessCallback, isFeatureEnabled, isBoundingBoxOrConfidenceScoreReviewEnabled, + metadataNamespaceMode, ], ); @@ -281,7 +301,9 @@ function useSidebarMetadataFetcher( return []; } - const templateInstance = templates.find(template => template.templateKey === templateKey && template.scope); + const templateInstance = templates.find(template => + isSameMetadataTemplate(template, { templateKey, scope }), + ); const fields = templateInstance?.fields || []; return fields.map(field => { @@ -328,12 +350,34 @@ 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); + } + }, [file, fetchMetadata]); + return { clearExtractError: () => setExtractErrorCode(null), extractSuggestions, handleCreateMetadataInstance, handleDeleteMetadataInstance, handleUpdateMetadataInstance, + refetchMetadata, extractErrorCode, errorMessage, file, diff --git a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts index 3bb70b36eb..024040fa76 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; + 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..7035e3156a --- /dev/null +++ b/src/elements/content-sidebar/utils/metadataTemplateIdentity.ts @@ -0,0 +1,46 @@ +/** + * 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 = { + /** Optional on `@box/metadata-template-browser` MetadataTemplate; required at runtime for a match. */ + 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 == null || b.templateKey == null || 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 418daf8071..3c3dde8459 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.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" resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-1.53.26.tgz#fdb3ebb1e079ba899c491c5bc9971bc4402f63e2" @@ -1527,6 +1532,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 +16057,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 +18749,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 +18847,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 +19038,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 +20086,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"