diff --git a/src/elements/content-sidebar/DocGenSidebar/DocGenSidebar.tsx b/src/elements/content-sidebar/DocGenSidebar/DocGenSidebar.tsx index 3f35cddd7a..f93f019bc3 100644 --- a/src/elements/content-sidebar/DocGenSidebar/DocGenSidebar.tsx +++ b/src/elements/content-sidebar/DocGenSidebar/DocGenSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import classNames from 'classnames'; import flow from 'lodash/flow'; -import { useIntl } from 'react-intl'; +import { useIntl, type MessageDescriptor } from 'react-intl'; import { LoadingIndicator } from '@box/blueprint-web'; @@ -31,10 +31,75 @@ import { WithLoggerProps } from '../../../common/types/logging'; import commonMessages from '../../common/messages'; import './DocGenSidebar.scss'; -import { DocGenTag, DocGenTemplateTagsResponse, JsonPathsMap } from './types'; +import type { DocGenTag, DocGenTemplateTagsResponse, JsonPathsMap } from './types'; +import { PDF_FIELD_TAG_TYPES, isPdfFormFieldTagType } from './types'; const DEFAULT_RETRIES = 10; +type DocGenSection = { + id: string; + message: MessageDescriptor; + tree: JsonPathsMap; +}; + +const PDF_FIELD_TYPE_MESSAGES: Record<(typeof PDF_FIELD_TAG_TYPES)[number], MessageDescriptor> = { + checkbox: messages.checkboxTags, + radiobutton: messages.radiobuttonTags, + dropdown: messages.dropdownTags, +}; + +const buildJsonPathTree = (docGenTags: DocGenTag[]): JsonPathsMap => { + const pathTree: JsonPathsMap = {}; + + docGenTags.forEach(tag => { + tag.json_paths.forEach(jsonPath => { + const segments = jsonPath.split('.'); + segments.reduce((node, segment) => { + if (!node[segment]) node[segment] = {}; + return node[segment]; + }, pathTree); + }); + }); + + return pathTree; +}; + +const buildDocGenSections = (data: DocGenTag[]): DocGenSection[] => { + const result: DocGenSection[] = []; + const imageTags = data.filter(tag => tag.tag_type === 'image'); + // anything that is not an image tag or pdf tag would be treated as a text tag + const textTags = data.filter(tag => tag.tag_type !== 'image' && !isPdfFormFieldTagType(tag.tag_type)); + + if (textTags.length > 0) { + result.push({ + id: 'text', + message: messages.textTags, + tree: buildJsonPathTree(textTags), + }); + } + + if (imageTags.length > 0) { + result.push({ + id: 'image', + message: messages.imageTags, + tree: buildJsonPathTree(imageTags), + }); + } + + PDF_FIELD_TAG_TYPES.forEach(fieldType => { + const fieldTags = data.filter(tag => tag.tag_type === fieldType); + if (fieldTags.length > 0) { + result.push({ + id: fieldType, + message: PDF_FIELD_TYPE_MESSAGES[fieldType], + tree: buildJsonPathTree(fieldTags), + }); + } + }); + + return result; +}; + type ExternalProps = { enabled: boolean; getDocGenTags: () => Promise; @@ -44,49 +109,12 @@ type ExternalProps = { type Props = ExternalProps & ErrorContextProps & WithLoggerProps; -type TagState = { - text: DocGenTag[]; - image: DocGenTag[]; -}; - -type JsonPathsState = { - textTree: JsonPathsMap; - imageTree: JsonPathsMap; -}; - const DocGenSidebar = ({ getDocGenTags }: Props) => { const { formatMessage } = useIntl(); const [hasError, setHasError] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [tags, setTags] = useState({ - text: [], - image: [], - }); - const [jsonPaths, setJsonPaths] = useState({ - textTree: {}, - imageTree: {}, - }); - - const createNestedObject = (base: JsonPathsMap, paths: string[]) => { - paths.reduce((obj, path) => { - if (!obj[path]) obj[path] = {}; - return obj[path]; - }, base); - }; - - const tagsToJsonPaths = useCallback((docGenTags: DocGenTag[]): JsonPathsMap => { - const jsonPathsMap: JsonPathsMap = {}; - - docGenTags.forEach(tag => { - tag.json_paths.forEach(jsonPath => { - const paths = jsonPath.split('.'); - createNestedObject(jsonPathsMap, paths); - }); - }); - - return jsonPathsMap; - }, []); + const [sections, setSections] = useState([]); const loadTags = useCallback( async (attempts = DEFAULT_RETRIES) => { @@ -101,17 +129,7 @@ const DocGenSidebar = ({ getDocGenTags }: Props) => { loadTags.call(this, attempts - 1); } else if (response?.data) { const { data } = response; - // anything that is not an image tag for this view is treated as a text tag - const textTags = data?.filter(tag => tag.tag_type !== 'image') || []; - const imageTags = data?.filter(tag => tag.tag_type === 'image') || []; - setTags({ - text: textTags, - image: imageTags, - }); - setJsonPaths({ - textTree: tagsToJsonPaths(textTags), - imageTree: tagsToJsonPaths(imageTags), - }); + setSections(buildDocGenSections(data)); setHasError(false); setIsLoading(false); } else { @@ -125,14 +143,14 @@ const DocGenSidebar = ({ getDocGenTags }: Props) => { }, // disabling eslint because the getDocGenTags prop is changing very frequently // eslint-disable-next-line react-hooks/exhaustive-deps - [tagsToJsonPaths], + [], ); useEffect(() => { loadTags(DEFAULT_RETRIES); }, [loadTags]); - const isEmpty = tags.image.length + tags.text.length === 0; + const isEmpty = sections.length === 0; return ( @@ -147,8 +165,9 @@ const DocGenSidebar = ({ getDocGenTags }: Props) => { {!hasError && !isLoading && isEmpty && } {!hasError && !isLoading && !isEmpty && ( <> - - + {sections.map(section => ( + + ))} )} diff --git a/src/elements/content-sidebar/DocGenSidebar/messages.tsx b/src/elements/content-sidebar/DocGenSidebar/messages.tsx index 7c43f97a4e..65b9c5c961 100644 --- a/src/elements/content-sidebar/DocGenSidebar/messages.tsx +++ b/src/elements/content-sidebar/DocGenSidebar/messages.tsx @@ -11,6 +11,21 @@ const messages = defineMessages({ description: 'Image tags section header', defaultMessage: 'Image tags', }, + checkboxTags: { + id: 'be.docGenSidebar.checkboxTags', + description: 'Checkbox tags section header', + defaultMessage: 'Checkbox tags', + }, + radiobuttonTags: { + id: 'be.docGenSidebar.radiobuttonTags', + description: 'Radiobutton tags section header', + defaultMessage: 'Radiobutton tags', + }, + dropdownTags: { + id: 'be.docGenSidebar.dropdownTags', + description: 'Dropdown tags section header', + defaultMessage: 'Dropdown tags', + }, docGenTags: { id: 'be.docGenSidebar.docGenTags', description: 'DocGen sidebar header', diff --git a/src/elements/content-sidebar/DocGenSidebar/stories/DocGenSidebar.stories.tsx b/src/elements/content-sidebar/DocGenSidebar/stories/DocGenSidebar.stories.tsx index 289d33f621..5043d1a88d 100644 --- a/src/elements/content-sidebar/DocGenSidebar/stories/DocGenSidebar.stories.tsx +++ b/src/elements/content-sidebar/DocGenSidebar/stories/DocGenSidebar.stories.tsx @@ -4,7 +4,7 @@ import type { HttpHandler } from 'msw'; import type { Meta } from '@storybook/react'; import ContentSidebar from '../../ContentSidebar'; import { mockFileRequest } from '../../stories/__mocks__/ContentSidebarMocks'; -import mockDocGenTags from '../../__mocks__/DocGenSidebar.mock'; +import mockDocGenTags, { mockPdfTemplateData } from '../../__mocks__/DocGenSidebar.mock'; const defaultArgs = { detailsSidebarProps: { @@ -33,6 +33,16 @@ const docGenSidebarProps = { }), }; +const docGenSidebarPdfTemplateProps = { + enabled: true, + isDocGenTemplate: true, + checkDocGenTemplate: noop, + getDocGenTags: async () => ({ + pagination: {}, + data: mockPdfTemplateData, + }), +}; + export const basic = { args: { defaultView: 'docgen', @@ -52,6 +62,25 @@ export const withModernizedBlueprint = { }, }; +export const pdfTemplate = { + args: { + defaultView: 'docgen', + docGenSidebarProps: docGenSidebarPdfTemplateProps, + }, +}; + +export const pdfTemplateWithModernizedBlueprint = { + args: { + enableModernizedComponents: true, + defaultView: 'docgen', + docGenSidebarProps: docGenSidebarPdfTemplateProps, + features: { + ...global.FEATURE_FLAGS, + previewModernization: { enabled: true }, + }, + }, +}; + const meta: Meta & { parameters: { msw: { handlers: HttpHandler[] } } } = { title: 'Elements/ContentSidebar/DocGenSidebar', component: ContentSidebar, diff --git a/src/elements/content-sidebar/DocGenSidebar/types.ts b/src/elements/content-sidebar/DocGenSidebar/types.ts index 2730d827e0..60c6c5f7ce 100644 --- a/src/elements/content-sidebar/DocGenSidebar/types.ts +++ b/src/elements/content-sidebar/DocGenSidebar/types.ts @@ -1,11 +1,21 @@ // our apis are in snake case export type DocGenTag = { /* eslint-disable-next-line camelcase */ - tag_type: 'text' | 'arithmetic' | 'conditional' | 'for-loop' | 'table-loop' | 'image'; + tag_type: + | 'text' + | 'arithmetic' + | 'conditional' + | 'for-loop' + | 'table-loop' + | 'image' + | 'checkbox' + | 'radiobutton' + | 'dropdown'; /* eslint-disable-next-line camelcase */ tag_content: string; /* eslint-disable-next-line camelcase */ json_paths: Array; + required: boolean; }; export type DocGenTemplateTagsResponse = { @@ -20,3 +30,10 @@ export type DocGenTemplateTagsResponse = { export interface JsonPathsMap { [key: string]: JsonPathsMap | {}; } + +/** PDF template control tags that render in their own sidebar section. */ +export const PDF_FIELD_TAG_TYPES = ['checkbox', 'radiobutton', 'dropdown'] as const; + +export function isPdfFormFieldTagType(tagType: DocGenTag['tag_type']): boolean { + return (PDF_FIELD_TAG_TYPES as readonly DocGenTag['tag_type'][]).includes(tagType); +} diff --git a/src/elements/content-sidebar/__mocks__/DocGenSidebar.mock.ts b/src/elements/content-sidebar/__mocks__/DocGenSidebar.mock.ts index 97edb53e59..d13d5478df 100644 --- a/src/elements/content-sidebar/__mocks__/DocGenSidebar.mock.ts +++ b/src/elements/content-sidebar/__mocks__/DocGenSidebar.mock.ts @@ -3,113 +3,163 @@ const mockData = [ tag_content: '{{ isActive }}', tag_type: 'text', json_paths: ['isActive'], + required: true, }, { tag_content: '{{ about }}', tag_type: 'text', json_paths: ['about', 'about.name'], + required: true, }, { tag_content: '{{ phone }}', tag_type: 'text', json_paths: ['phone'], + required: true, }, { tag_content: '{{ company }}', tag_type: 'text', json_paths: ['company', 'company.name'], + required: true, }, { tag_content: '{{contract.customerName}}', tag_type: 'text', json_paths: ['contract', 'contract.customerName'], + required: true, }, { tag_content: '{{contract.customerAddress.street}}', tag_type: 'text', json_paths: ['contract', 'contract.customerAddress', 'contract.customerAddress.street'], + required: true, }, { tag_content: '{{contract.customerAddress.city}}', tag_type: 'text', json_paths: ['contract', 'contract.customerAddress', 'contract.customerAddress.city'], + required: true, }, { tag_content: '{{if contract.country == “UK”}}', tag_type: 'conditional', json_paths: ['contract', 'contract.country'], + required: true, }, { tag_content: '{{if contract.country == “1111” and contract.city == “London” }}', tag_type: 'conditional', json_paths: ['contract', 'contract.country', 'contract.city'], + required: true, }, { tag_content: '{{elseif contract.country == “JAPAN” and contract.city == “Tokyo“}}', tag_type: 'conditional', json_paths: ['contract', 'contract.country', 'contract.city'], + required: true, }, { tag_content: '{{invoice.image}}', tag_type: 'image', json_paths: ['invoice', 'invoice.image'], + required: true, }, { tag_content: '{{item.quantity * item.price}}', tag_type: 'arithmetic', json_paths: ['products', 'products.quantity', 'products.price'], + required: true, }, { tag_content: '{{tablerow item in products }}', tag_type: 'table-loop', json_paths: ['products'], + required: true, }, { tag_content: '{{item.name}}', tag_type: 'text', json_paths: ['products', 'products.name', 'products.quantity', 'products.price'], + required: true, }, { tag_content: '{{item.quantity * item.price}}', tag_type: 'arithmetic', json_paths: ['products', 'products.quantity', 'products.price'], + required: true, }, { tag_content: '{{$sum(products.amount)}}', tag_type: 'arithmetic', json_paths: ['products', 'products.amount'], + required: true, }, { tag_content: '{{invoice.id}}', tag_type: 'text', json_paths: ['invoice', 'invoice.id'], + required: true, }, { tag_content: '{{invoice.date}}', tag_type: 'text', json_paths: ['invoice', 'invoice.date'], + required: true, }, { tag_content: '{{invoice.billingAddress.street::uppercase}}', tag_type: 'text', json_paths: ['invoice', 'invoice.billingAddress', 'invoice.billingAddress.street'], + required: true, }, { tag_content: '{{tablerow item in products }}', tag_type: 'table-loop', json_paths: ['products', 'products.name', 'products.description', 'products.quantity', 'products.price'], + required: true, }, { tag_content: '{{item.name}}', tag_type: 'text', json_paths: ['products', 'products.name', 'products.quantity', 'products.price'], + required: true, }, { tag_content: '{{item.quantity * item.price}}', tag_type: 'arithmetic', json_paths: ['products', 'products.quantity', 'products.price'], + required: true, + }, +]; + +/** PDF template tags (text, checkbox, radiobutton, dropdown) */ +export const mockPdfTemplateData = [ + { + tag_type: 'text', + tag_content: '{{NameField::optional}}', + json_paths: ['NameField'], + required: false, + }, + { + tag_type: 'checkbox', + tag_content: '{{SubscribeCheckbox}}', + json_paths: ['SubscribeCheckbox'], + required: true, + }, + { + tag_type: 'radiobutton', + tag_content: '{{Gender}}', + json_paths: ['Gender'], + required: true, + }, + { + tag_type: 'dropdown', + tag_content: '{{CountryDropdown}}', + json_paths: ['CountryDropdown'], + required: true, }, ]; diff --git a/src/elements/content-sidebar/__tests__/DocGenSidebar.test.tsx b/src/elements/content-sidebar/__tests__/DocGenSidebar.test.tsx index 8539a66f68..2ca1a22e21 100644 --- a/src/elements/content-sidebar/__tests__/DocGenSidebar.test.tsx +++ b/src/elements/content-sidebar/__tests__/DocGenSidebar.test.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { render, screen, waitFor, fireEvent } from '../../../test-utils/testing-library'; import { DocGenSidebarComponent as DocGenSidebar } from '../DocGenSidebar/DocGenSidebar'; -import mockData from '../__mocks__/DocGenSidebar.mock'; +import type { DocGenTag } from '../DocGenSidebar/types'; +import mockData, { mockPdfTemplateData } from '../__mocks__/DocGenSidebar.mock'; const docGenSidebarProps = { getDocGenTags: jest.fn().mockReturnValue( @@ -27,7 +28,6 @@ const processAndResolveMock = jest }), ); -const noTagsMock = jest.fn().mockReturnValue(Promise.resolve({ data: [] })); const processingTagsMock = jest.fn().mockReturnValue( Promise.resolve({ message: 'Processing tags for this file.', @@ -56,8 +56,48 @@ describe('elements/content-sidebar/DocGenSidebar', () => { test('should render DocGen sidebar component correctly with tags list', async () => { renderComponent(); - const tagList = await screen.findAllByTestId('bcs-TagsSection'); - expect(tagList).toHaveLength(2); + expect(await screen.findByText('Text tags')).toBeInTheDocument(); + expect(await screen.findByText('Image tags')).toBeInTheDocument(); + }); + + test('should show empty state when the API returns no tags', async () => { + renderComponent({ + getDocGenTags: jest.fn().mockReturnValue(Promise.resolve({ pagination: {}, data: [] })), + }); + expect(await screen.findByText('This document has no tags')).toBeInTheDocument(); + expect(screen.queryByText('Text tags')).not.toBeInTheDocument(); + }); + + test('should render PDF form field tags in separate sections', async () => { + renderComponent({ + getDocGenTags: jest.fn().mockReturnValue( + Promise.resolve({ + pagination: {}, + data: mockPdfTemplateData, + }), + ), + }); + + expect(await screen.findByText('Text tags')).toBeInTheDocument(); + expect(await screen.findByText('Checkbox tags')).toBeInTheDocument(); + expect(await screen.findByText('Radiobutton tags')).toBeInTheDocument(); + expect(await screen.findByText('Dropdown tags')).toBeInTheDocument(); + }); + + test('should list tags with an unknown `tag_type` under Text tags', async () => { + const data: DocGenTag[] = [ + { + tag_type: 'unknown', + tag_content: '{{x}}', + json_paths: ['pathFromUnknown'], + required: true, + } as unknown as DocGenTag, + ]; + renderComponent({ + getDocGenTags: jest.fn().mockReturnValue(Promise.resolve({ pagination: {}, data })), + }); + expect(await screen.findByText('Text tags')).toBeInTheDocument(); + expect(await screen.findByText('pathFromUnknown')).toBeInTheDocument(); }); test('should render DocGen sidebar component correctly with tags list', async () => { @@ -74,15 +114,6 @@ describe('elements/content-sidebar/DocGenSidebar', () => { expect(nestedTag).toBeInTheDocument(); }); - test('should render empty state when there are no tags', async () => { - renderComponent({ - getDocGenTags: noTagsMock, - }); - - const emptyState = await screen.findByText('This document has no tags'); - expect(emptyState).toBeInTheDocument(); - }); - test('should render loading state', async () => { const mockGetDocGenTags = jest.fn().mockReturnValue( new Promise(resolve => {