diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree/tree.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree/tree.tsx new file mode 100644 index 000000000000..9d799eb3eee2 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree/tree.tsx @@ -0,0 +1,321 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { cx } from '@/utils/cx'; +import { ChevronRight, RefreshCw01 } from '@untitledui/icons'; +import type { + ComponentPropsWithRef, + ComponentType, + HTMLAttributes, + ReactNode, +} from 'react'; +import type { + TreeItemProps as AriaTreeItemProps, + TreeLoadMoreItemProps as AriaTreeLoadMoreItemProps, + TreeProps as AriaTreeProps, + Key, + Selection, + TreeItemContentRenderProps, +} from 'react-aria-components'; +import { + Button as AriaButton, + Tree as AriaTree, + TreeHeader as AriaTreeHeader, + TreeItem as AriaTreeItem, + TreeItemContent as AriaTreeItemContent, + TreeLoadMoreItem as AriaTreeLoadMoreItem, + TreeSection as AriaTreeSection, +} from 'react-aria-components'; + +export type { Key, Selection }; + +// ---- Tree --------------------------------------------------------------- + +export interface TreeProps + extends Omit, 'className' | 'children'> { + /** Additional CSS class name for the tree container. */ + className?: string; + /** The contents of the tree. */ + children?: ReactNode | ((item: T) => ReactNode); +} + +const TreeRoot = ({ + className, + children, + ...props +}: TreeProps) => { + return ( + + {children as AriaTreeProps['children']} + + ); +}; + +// ---- TreeItem ----------------------------------------------------------- + +export interface TreeItemProps + extends Omit, 'children'> { + /** The content of this tree item. Typically `Tree.ItemContent`. */ + children: ReactNode; + /** Additional CSS class name applied to the item row. */ + className?: string | AriaTreeItemProps['className']; +} + +const TreeItemComponent = ({ + className, + children, + ...props +}: TreeItemProps) => { + return ( + + cx( + 'tw:group/tree-item tw:outline-none tw:rounded-md', + 'tw:cursor-pointer tw:select-none', + state.isDisabled && 'tw:opacity-50 tw:cursor-not-allowed', + state.isFocusVisible && 'tw:ring-2 tw:ring-inset tw:ring-brand-300', + typeof className === 'function' ? className(state) : className + ) + }> + {children} + + ); +}; + +// ---- TreeItemContent ---------------------------------------------------- + +export interface TreeItemContentProps { + /** + * The content to render. Can be static ReactNode or a render function + * receiving `TreeItemContentRenderProps`. + */ + children: + | ReactNode + | ((renderProps: TreeItemContentRenderProps) => ReactNode); + /** Additional CSS class name for the content wrapper. */ + className?: string; + /** + * Optional icon component rendered between the chevron and the label. + * Accepts any `@untitledui/icons`-compatible component. + */ + icon?: ComponentType>; + /** Additional CSS class name applied to the icon. */ + iconClassName?: string; + /** + * When `true`, an animated expand/collapse chevron is rendered automatically. + * Set to `false` to render your own expand indicator. Defaults to `true`. + */ + showExpandIcon?: boolean; +} + +const TreeItemContentComponent = ({ + className, + children, + icon: Icon, + iconClassName, + showExpandIcon = true, +}: TreeItemContentProps) => { + return ( + + {(renderProps: TreeItemContentRenderProps) => { + const { isExpanded, hasChildItems, level } = renderProps; + + return ( +
+ {showExpandIcon && ( +
+ ); + }} +
+ ); +}; + +// ---- TreeLoadMoreItem --------------------------------------------------- + +export type TreeLoadMoreItemProps = Omit< + AriaTreeLoadMoreItemProps, + 'className' | 'children' +> & { + /** Additional CSS class name for the load-more row. */ + className?: string; + /** Content shown inside the row. Defaults to a spinner + "Loading…" / "Load more". */ + children?: ReactNode; +}; + +const TreeLoadMoreItemComponent = ({ + isLoading, + children, + className, + ...props +}: TreeLoadMoreItemProps) => { + return ( + + {isLoading ? ( + + + ) : ( + children ?? 'Load more' + )} + + ); +}; + +// ---- TreeSection -------------------------------------------------------- + +export type TreeSectionProps = ComponentPropsWithRef< + typeof AriaTreeSection +>; + +const TreeSectionComponent = (props: TreeSectionProps) => ( + +); + +// ---- TreeHeader --------------------------------------------------------- + +export interface TreeHeaderProps { + children?: ReactNode; + className?: string; +} + +const TreeHeaderComponent = ({ className, children }: TreeHeaderProps) => { + return ( + + {children} + + ); +}; + +// ---- TreeExpandButton --------------------------------------------------- + +export interface TreeExpandButtonProps + extends Omit, 'className'> { + /** Additional CSS class name. */ + className?: string; +} + +/** + * An accessible expand/collapse button for use inside `Tree.ItemContent`. + * Render with slot="chevron" so React Aria's Tree handles keyboard & ARIA. + */ +const TreeExpandButton = ({ className, ...props }: TreeExpandButtonProps) => { + return ( + + cx( + 'tw:flex tw:items-center tw:justify-center tw:w-4 tw:h-4 tw:shrink-0', + 'tw:rounded tw:outline-none tw:text-fg-quaternary', + 'tw:transition-transform tw:duration-200 tw:ease-in-out', + state.isFocusVisible && 'tw:ring-2 tw:ring-brand-300', + className + ) + } + slot="chevron"> + + ); +}; + +// ---- Compound export ---------------------------------------------------- + +/** + * Tree renders a hierarchical list of items with keyboard navigation, + * selection, and expand/collapse support. Built on React Aria's Tree + * primitives for full accessibility. + * + * @example + * ```tsx + * + * + * Databases + * + * Postgres + * + * + * + * ``` + */ +const _Tree = TreeRoot as typeof TreeRoot & { + Item: typeof TreeItemComponent; + ItemContent: typeof TreeItemContentComponent; + LoadMoreItem: typeof TreeLoadMoreItemComponent; + Section: typeof TreeSectionComponent; + Header: typeof TreeHeaderComponent; + ExpandButton: typeof TreeExpandButton; +}; + +_Tree.Item = TreeItemComponent; +_Tree.ItemContent = TreeItemContentComponent; +_Tree.LoadMoreItem = TreeLoadMoreItemComponent; +_Tree.Section = TreeSectionComponent; +_Tree.Header = TreeHeaderComponent; +_Tree.ExpandButton = TreeExpandButton; + +export { + _Tree as Tree, + TreeExpandButton, + TreeHeaderComponent as TreeHeader, + TreeItemComponent as TreeItem, + TreeItemContentComponent as TreeItemContent, + TreeLoadMoreItemComponent as TreeLoadMoreItem, + TreeSectionComponent as TreeSection, +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts index 0e25cee0d5d4..e01fd5b9fb27 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts @@ -93,6 +93,7 @@ export { getField, } from './application/form-field/form-field'; export * from './application/accordion/accordion'; +export * from './application/tree/tree'; export { MobileNavigationHeader } from './application/app-navigation/base-components/mobile-header'; export { NavAccountCard, diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Tree.stories.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Tree.stories.tsx new file mode 100644 index 000000000000..c99447b161dc --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Tree.stories.tsx @@ -0,0 +1,406 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import type { Key, Selection } from 'react-aria-components'; +import { Tree } from '../components/application/tree/tree'; + +const meta = { + title: 'Components/Tree', + component: Tree, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ + + src/ + + components/ + + + Button.tsx + + + + + Input.tsx + + + + + utils/ + + cx.ts + + + + + public/ + + + index.html + + + + +
+ ), +}; + +export const DefaultExpanded: StoryObj = { + render: () => ( +
+ + + public + + Tables + + orders + + + + customers + + + + + Views + + + order_summary + + + + + +
+ ), +}; + +export const SingleSelection: StoryObj = { + render: () => { + const [selected, setSelected] = useState(new Set(['orders'])); + + return ( +
+

+ Selected:{' '} + {selected === 'all' + ? 'all' + : [...(selected as Set)].join(', ') || 'none'} +

+ + + Tables + + orders + + + + customers + + + + + products + + + + + Views + + + order_summary + + + + +
+ ); + }, +}; + +export const MultipleSelection: StoryObj = { + render: () => { + const [selected, setSelected] = useState( + new Set(['orders', 'customers']) + ); + + return ( +
+

+ Selected:{' '} + {selected === 'all' + ? 'all' + : [...(selected as Set)].join(', ') || 'none'} +

+ + + Databases + + orders + + + + customers + + + + + products + + + + +
+ ); + }, +}; + +export const WithDisabledItems: StoryObj = { + render: () => ( +
+ + + Tables + + orders + + + + customers (disabled) + + + + products + + + +
+ ), +}; + +export const WithAction: StoryObj = { + render: () => { + const [lastAction, setLastAction] = useState(''); + + return ( +
+

+ Last action: {lastAction || 'none'} +

+ setLastAction(String(key))}> + + Pipeline + + + extract + + + + + transform + + + + load + + + +
+ ); + }, +}; + +export const EmptyState: StoryObj = { + render: () => ( +
+ ( +
+ No items found +
+ )}> + {[]} +
+
+ ), +}; + +export const WithLoadMore: StoryObj = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + const [items, setItems] = useState(['orders', 'customers', 'products']); + + const handleLoadMore = () => { + setIsLoading(true); + setTimeout(() => { + setItems((prev) => [...prev, 'invoices', 'shipments']); + setIsLoading(false); + }, 1500); + }; + + return ( +
+ + + Tables + {items.map((item) => ( + + + {item} + + + ))} + + + +
+ ); + }, +}; + +export const DeeplyNested: StoryObj = { + render: () => ( +
+ + + Collate + + Data Engineering + + Pipelines + + ETL + + + daily_orders + + + + + hourly_events + + + + + + + +
+ ), +}; + +export const ControlledExpansion: StoryObj = { + render: () => { + const [expanded, setExpanded] = useState>(new Set(['root'])); + + return ( +
+
+ + +
+ + + Root + + Group A + + + Item A1 + + + + + Item A2 + + + + + Group B + + + Item B1 + + + + + +
+ ); + }, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenter.spec.ts index bc47a103f72f..e18254f1e075 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenter.spec.ts @@ -141,24 +141,14 @@ test.describe('Context Center', () => { // Upload a document via API so document-related tests have data const fileContent = Buffer.from('Playwright seed document'); - const formData = new FormData(); - formData.append( - 'file', - new Blob([fileContent], { type: 'text/plain' }), - 'seed-document.txt' - ); - formData.append('entityLink', '<#E::page::contextCenter.documents>'); - formData.append('assetType', 'External'); - await apiContext.post('/api/v1/attachments/upload', { + await apiContext.post('/api/v1/contextCenter/drive/files/upload', { multipart: { file: { name: 'seed-document.txt', mimeType: 'text/plain', buffer: fileContent, }, - entityLink: '<#E::page::contextCenter.documents>', - assetType: 'External', }, }); @@ -492,6 +482,148 @@ test.describe('Context Center', () => { }); }); + // ─── Search ────────────────────────────────────────────────────────────────── + + test.describe('Search', () => { + test('searching articles filters the list to matching results', async ({ + page, + }) => { + await navigateToArticles(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Articles'); + await searchInput.fill(ARTICLE_TITLE); + + // Wait for search results to update + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + // The pre-created article appears in results + const card = page.getByTestId(ARTICLE_TITLE); + + await expect(card.first()).toBeVisible(); + }); + + test('searching articles with no match shows empty state', async ({ + page, + }) => { + await navigateToArticles(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Articles'); + await searchInput.fill('zzznomatchzzz_playwright'); + + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + await expect(page.getByTestId('no-data-placeholder')).toBeVisible({ + timeout: 8000, + }); + }); + + test('clearing article search restores the full list', async ({ page }) => { + await navigateToArticles(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Articles'); + + await searchInput.fill('zzznomatch'); + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + await searchInput.clear(); + await waitForAllLoadersToDisappear(page); + + const card = page.getByTestId(ARTICLE_TITLE); + await expect(card.first()).toBeVisible(); + }); + + test('searching documents filters the list to matching results', async ({ + page, + }) => { + await navigateToDocuments(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Documents'); + await searchInput.fill('seed-document'); + + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + const view = page.getByTestId('documents-view'); + const rows = view.locator('[data-testid^="document-row-"]'); + const count = await rows.count(); + + // If the seed document was indexed, it appears; otherwise the empty state shows + if (count > 0) { + await expect(rows.first()).toBeVisible(); + } else { + await expect(page.getByTestId('no-data-placeholder')).toBeVisible({ + timeout: 8000, + }); + } + }); + + test('searching documents with no match shows empty state', async ({ + page, + }) => { + await navigateToDocuments(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Documents'); + await searchInput.fill('zzznomatchzzz_playwright'); + + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + await expect(page.getByTestId('no-data-placeholder')).toBeVisible({ + timeout: 8000, + }); + }); + + test('clearing document search restores the full list', async ({ + page, + }) => { + await navigateToDocuments(page); + + const header = page.getByTestId('context-center-header'); + const searchInput = header + .getByTestId('search-input') + .getByLabel('Search Documents'); + + await searchInput.fill('zzznomatch'); + await page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && res.status() === 200 + ); + + await searchInput.clear(); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('documents-view')).toBeVisible(); + }); + }); + // ─── Articles Page ─────────────────────────────────────────────────────────── test.describe('Articles Page', () => { @@ -736,7 +868,7 @@ test.describe('Context Center', () => { await card.getByTestId('delete-quick-link-btn').click(); const deleteRes = page.waitForResponse( - '/api/v1/contextCenter/pages/*?hardDelete=true&recursive=false' + /\/api\/v1\/contextCenter\/pages\/[^?]+\?recursive=false&hardDelete=true/ ); await page.getByTestId('confirm-button').click(); const res = await deleteRes; @@ -782,7 +914,7 @@ test.describe('Context Center', () => { await page.getByTestId('delete-btn').click(); const apiDeleteRes = page.waitForResponse( - /\/api\/v1\/contextCenter\/pages\/.+\?hardDelete=true/ + /\/api\/v1\/contextCenter\/pages\/[^?]+\?recursive=true&hardDelete=false/ ); await page.getByTestId('confirm-button').click(); await apiDeleteRes; @@ -869,7 +1001,9 @@ test.describe('Context Center', () => { await expect(modal.getByText('test-upload.txt').first()).toBeVisible(); // Attach the file - const uploadRes = page.waitForResponse('/api/v1/attachments/upload'); + const uploadRes = page.waitForResponse( + '/api/v1/contextCenter/drive/files/upload' + ); await modal.getByRole('button', { name: /attach/i }).click(); // Progress bar / uploading state @@ -912,7 +1046,7 @@ test.describe('Context Center', () => { await expect(downloadBtn).toBeVisible(); }); - test('download button triggers file download', async ({ page }) => { + test.fixme('download button triggers file download', async ({ page }) => { await navigateToDocuments(page); const view = page.getByTestId('documents-view'); @@ -920,47 +1054,62 @@ test.describe('Context Center', () => { await expect(firstRow).toBeVisible(); - // Listen for the download API call — download triggers /api/v1/assets/:id/download + // Listen for the download API call const downloadRes = page.waitForResponse( - /\/api\/v1\/attachments\/[^/]+\/download(?:\?.*)?$/ + /\/api\/v1\/contextCenter\/drive\/files\/[^/]+\/download(?:\?.*)?$/ ); await firstRow.locator('button').nth(0).click(); const res = await downloadRes; expect(res.status()).toBe(200); }); - test('delete document removes it from the list', async ({ page }) => { + test('delete document removes it from the list', async ({ + browser, + page, + }) => { + const fileName = `delete-doc-${uuid()}.txt`; + + // Upload a dedicated document so this test is independent of the download test + const { apiContext, afterAction } = await createNewPage(browser); + await apiContext.post('/api/v1/contextCenter/drive/files/upload', { + multipart: { + file: { + name: fileName, + mimeType: 'text/plain', + buffer: Buffer.from('document for delete test'), + }, + }, + }); + await afterAction(); + await navigateToDocuments(page); const view = page.getByTestId('documents-view'); - const firstRow = view.locator('[data-testid^="document-row-"]').first(); + const targetRow = view.locator(`[data-testid^="document-row-"]`).filter({ + has: page.getByText(fileName), + }); - // Relies on at least one document existing from prior upload test - await expect(firstRow).toBeVisible(); - await firstRow.scrollIntoViewIfNeeded(); + await expect(targetRow).toBeVisible(); + await targetRow.scrollIntoViewIfNeeded(); - const rowId = await firstRow.getAttribute('data-testid'); + const rowId = await targetRow.getAttribute('data-testid'); - // Open the three-dot actions dropdown (second button in the row, after download) - await firstRow.locator('button[aria-label="Open menu"]').click(); + await targetRow.locator('button[aria-label="Open menu"]').click(); - // Click Delete from the dropdown menu const deleteItem = page.getByTestId('delete-btn'); await expect(deleteItem).toBeVisible(); await deleteItem.click(); - // Confirm via DeleteModal from core-components const deleteModal = page.getByTestId('modal-header'); await expect(deleteModal).toBeVisible(); const deleteRes = page.waitForResponse( - /\/api\/v1\/attachments\/[^?]+\?hardDelete=true/ + /\/api\/v1\/contextCenter\/drive\/files\/[^?]+\?hardDelete=false/ ); await page.getByTestId('confirm-button').click(); const res = await deleteRes; expect(res.status()).toBe(200); - // Row is removed from the list if (rowId) { await expect(page.getByTestId(rowId)).not.toBeVisible(); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/KnowledgeCenterList.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/KnowledgeCenterList.spec.ts index 5df7d07c161e..c0ac7cf860a0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/KnowledgeCenterList.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/KnowledgeCenterList.spec.ts @@ -250,7 +250,7 @@ test.describe('Knowledge Center List', () => { const paginationResponse = page.waitForResponse( (response) => response.url().includes('/api/v1/contextCenter/pages') && - response.url().includes('after=') + response.url().includes('offset=') ); await observerElement.scrollIntoViewIfNeeded(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/KnowledgeCenter.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/KnowledgeCenter.ts index dc8677a6a365..0b26fdd52611 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/KnowledgeCenter.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/KnowledgeCenter.ts @@ -41,7 +41,7 @@ export const deletePage = async ( await expect(page.getByTestId('confirm-button')).toBeVisible(); const deleteResponse = page.waitForResponse( - `/api/v1/contextCenter/pages/*?hardDelete=true&recursive=${!isQuickLink}` + `/api/v1/contextCenter/pages/*?recursive=${!isQuickLink}&hardDelete=${isQuickLink}` ); // Register before clicking so we don't miss the response the app fires diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/ContextCenterRouter/ContextCenterRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/ContextCenterRouter/ContextCenterRouter.tsx index 88efb2a97b10..0548233f6bb9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/ContextCenterRouter/ContextCenterRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/ContextCenterRouter/ContextCenterRouter.tsx @@ -60,27 +60,15 @@ const KnowledgeCenterFilterPage = withSuspenseFallback( ) ) ); -{ - /* TODO: In progress */ -} -// const ContextCenterIntegrationsPage = withSuspenseFallback( -// React.lazy( -// () => -// import( -// '../../../pages/ContextCenterPage/ContextCenterIntegrationsPage/ContextCenterIntegrationsPage' -// ) -// ) -// ); - -// const ContextCenterArchivePage = withSuspenseFallback( -// React.lazy( -// () => -// import( -// '../../../pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage' -// ) -// ) -// ); +const ContextCenterArchivePage = withSuspenseFallback( + React.lazy( + () => + import( + '../../../pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage' + ) + ) +); const ContextCenterRouter = () => { return ( @@ -124,18 +112,10 @@ const ContextCenterRouter = () => { element={} path={ROUTES.CONTEXT_CENTER_FILTER.replace(ROUTES.CONTEXT_CENTER, '')} /> - {/* TODO: In progress */} - {/* } - path={ROUTES.CONTEXT_CENTER_INTEGRATIONS.replace( - ROUTES.CONTEXT_CENTER, - '' - )} - /> } path={ROUTES.CONTEXT_CENTER_ARCHIVE.replace(ROUTES.CONTEXT_CENTER, '')} - /> */} + /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.component.tsx new file mode 100644 index 000000000000..3571ec4c1b7b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.component.tsx @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + Card, + Skeleton, + Typography, +} from '@openmetadata/ui-core-components'; +import { File06, RefreshCcw01, Trash01 } from '@untitledui/icons'; +import classNames from 'classnames'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as FolderIcon } from '../../../assets/svg/ic-folder-new.svg'; +import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; +import { getShortRelativeTime } from '../../../utils/date-time/DateTimeUtils'; +import { ArchiveItem, ArchiveViewProps } from './ArchiveView.interface'; + +const ArchiveRowSkeleton: FC = () => ( +
+ +
+ + +
+
+ + +
+
+); + +interface ArchiveRowProps { + item: ArchiveItem; + onRestore: (item: ArchiveItem) => void; + onDelete: (item: ArchiveItem) => void; +} + +const ArchiveRow: FC = ({ item, onDelete, onRestore }) => { + const { t } = useTranslation(); + + const Icon = item.type === 'article' ? File06 : FolderIcon; + + return ( +
+
+ +
+ +
+ + {item.name} + + + {item.updatedBy && ( + <> + {t('label.archived-by', { name: item.updatedBy })} + {item.updatedAt && ( + <> · {getShortRelativeTime(item.updatedAt)} + )} + + )} + {!item.updatedBy && + item.updatedAt && + getShortRelativeTime(item.updatedAt)} + +
+ +
+ + +
+
+ ); +}; + +const ArchiveView: FC = ({ + data, + isLoading, + onDelete, + onRestore, +}) => { + if (isLoading) { + return ( + + {Array.from({ length: 8 }).map((_, idx) => ( + + ))} + + ); + } + + if (data.length === 0) { + return ( + + + + ); + } + + return ( +
+ {data.map((item) => ( + + ))} +
+ ); +}; + +export default ArchiveView; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.interface.ts new file mode 100644 index 000000000000..8a978a0aae3c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArchiveView/ArchiveView.interface.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ArchiveItemType = 'article' | 'document'; + +export interface ArchiveItem { + id: string; + name: string; + type: ArchiveItemType; + updatedBy?: string; + updatedAt?: number; +} + +export interface ArchiveViewProps { + data: ArchiveItem[]; + isLoading: boolean; + onRestore: (item: ArchiveItem) => void; + onDelete: (item: ArchiveItem) => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx index 03c2dfd922c9..5d50c28b649d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx @@ -85,7 +85,6 @@ const ArticleDetailHeader: FC = ({ onToggleRightPanel, onVoteChange, onFollowChange, - onToggleDelete, onSave, onSetThreadLink, fetchKnowledgePageHierarchy, @@ -183,19 +182,13 @@ const ArticleDetailHeader: FC = ({ ); await fetchKnowledgePageHierarchy?.(true); setIsDeleteModalOpen(false); - onToggleDelete(); navigate(contextCenterClassBase.getArticlesListPath()); } catch (error) { showErrorToast(error as AxiosError); } finally { setIsDeleting(false); } - }, [ - knowledgePage, - recentlyViewed, - fetchKnowledgePageHierarchy, - onToggleDelete, - ]); + }, [knowledgePage, recentlyViewed, fetchKnowledgePageHierarchy]); const handleVersionClick = () => { navigate(contextCenterClassBase.getArticleVersionPath(fqn, version)); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.interface.ts index 5b2bf98beb89..f88cb178fbcf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.interface.ts @@ -33,7 +33,6 @@ export interface ArticleDetailHeaderProps { onToggleRightPanel: () => void; onVoteChange: (type: VotingDataProps) => Promise; onFollowChange: () => Promise; - onToggleDelete: () => void; onSave?: () => void; onSetThreadLink: (link: string) => void; fetchKnowledgePageHierarchy?: (forceRefresh?: boolean) => Promise; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx index ba97b6603929..fc08127e4410 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx @@ -11,8 +11,13 @@ * limitations under the License. */ -import { Button, Card, Typography } from '@openmetadata/ui-core-components'; -import { Plus, UploadCloud02 } from '@untitledui/icons'; +import { + Button, + Card, + Input, + Typography, +} from '@openmetadata/ui-core-components'; +import { Plus, SearchMd, UploadCloud02 } from '@untitledui/icons'; import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; @@ -27,6 +32,9 @@ const ContextCenterHeader: FC = ({ onCreateArticle, onUploadFile, actionsSlot, + searchQuery, + searchPlaceholder, + onSearch, }) => { const { t } = useTranslation(); const breadcrumbInsideCard = contextCenterClassBase.isBreadcrumbInsideCard(); @@ -78,7 +86,7 @@ const ContextCenterHeader: FC = ({ /> )} -
+
{title} @@ -87,7 +95,19 @@ const ContextCenterHeader: FC = ({ {subtitle} )}
- {hasPermission ? actionsSlot ?? defaultActions : null} +
+ {onSearch && ( + + )} + {hasPermission ? actionsSlot ?? defaultActions : null} +
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.interface.ts index 3a0003f12dcf..71d3678c40f6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.interface.ts @@ -23,4 +23,7 @@ export interface ContextCenterHeaderProps { onUploadFile?: () => void; /** Replaces the default action buttons with a custom slot */ actionsSlot?: React.ReactNode; + searchQuery?: string; + searchPlaceholder?: string; + onSearch?: (value: string) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.component.tsx new file mode 100644 index 000000000000..73a4a3fdc3a8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.component.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + Dialog, + Input, + Modal, + ModalOverlay, + Typography, +} from '@openmetadata/ui-core-components'; +import { FolderPlus } from '@untitledui/icons'; +import { AxiosError } from 'axios'; +import { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Folder } from '../../../generated/entity/data/folder'; +import { createFolder } from '../../../rest/assetAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; + +export interface CreateFolderModalProps { + isOpen: boolean; + onClose: () => void; + onCreated: (folder: Folder) => void; +} + +const CreateFolderModal: FC = ({ + isOpen, + onClose, + onCreated, +}) => { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + + const handleClose = () => { + setName(''); + onClose(); + }; + + const handleCreate = async () => { + const trimmed = name.trim(); + + if (!trimmed) { + return; + } + + try { + setIsCreating(true); + const folder = await createFolder({ + name: trimmed, + displayName: trimmed, + }); + onCreated(folder); + handleClose(); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setIsCreating(false); + } + }; + + return ( + !open && !isCreating && handleClose()}> + + + +
+ +
+ + {t('label.create-new-folder')} + +
+ + {t('label.entity-name', { entity: t('label.folder') })} + + setName(value)} + /> +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default CreateFolderModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.test.tsx new file mode 100644 index 000000000000..5197775de003 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/CreateFolderModal/CreateFolderModal.test.tsx @@ -0,0 +1,271 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { createFolder } from 'rest/assetAPI'; +import CreateFolderModal from './CreateFolderModal.component'; + +jest.mock('rest/assetAPI', () => ({ + createFolder: jest.fn(), +})); + +jest.mock('@openmetadata/ui-core-components', () => ({ + Button: jest.fn( + ({ + children, + onPress, + isDisabled, + isLoading, + 'data-testid': testId, + }: { + children: React.ReactNode; + onPress?: () => void; + isDisabled?: boolean; + isLoading?: boolean; + 'data-testid'?: string; + }) => ( + + ) + ), + Dialog: Object.assign( + jest.fn( + ({ + children, + title, + onClose, + }: { + children: React.ReactNode; + title: string; + onClose: () => void; + }) => ( +
+ {title} + + {children} +
+ ) + ), + { + Content: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + } + ), + Input: jest.fn( + ({ + value, + onChange, + 'data-testid': testId, + placeholder, + }: { + value: string; + onChange: (v: string) => void; + 'data-testid'?: string; + placeholder?: string; + }) => ( + onChange(e.target.value)} + /> + ) + ), + Modal: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + ModalOverlay: jest.fn( + ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => + isOpen ?
{children}
: null + ), + Typography: jest.fn(({ children }: { children: React.ReactNode }) => ( + {children} + )), +})); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onCreated: jest.fn(), +}; + +describe('CreateFolderModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders when isOpen is true', () => { + render(); + + expect(screen.getByTestId('modal-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + + expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument(); + }); + + it('renders the folder name input', () => { + render(); + + expect(screen.getByTestId('folder-name-input')).toBeInTheDocument(); + }); + + it('create button is disabled when input is empty', () => { + render(); + + expect(screen.getByTestId('create-folder-btn')).toBeDisabled(); + }); + + it('create button is disabled when input is only whitespace', () => { + render(); + + fireEvent.change(screen.getByTestId('folder-name-input'), { + target: { value: ' ' }, + }); + + expect(screen.getByTestId('create-folder-btn')).toBeDisabled(); + }); + + it('create button is enabled when input has a valid name', () => { + render(); + + fireEvent.change(screen.getByTestId('folder-name-input'), { + target: { value: 'My Folder' }, + }); + + expect(screen.getByTestId('create-folder-btn')).not.toBeDisabled(); + }); + + it('calls createFolder with trimmed name on submit', async () => { + const folder = { + id: 'folder-1', + name: 'my-folder', + displayName: 'My Folder', + }; + (createFolder as jest.Mock).mockResolvedValue(folder); + + render(); + + fireEvent.change(screen.getByTestId('folder-name-input'), { + target: { value: ' My Folder ' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('create-folder-btn')); + }); + + expect(createFolder).toHaveBeenCalledWith({ + name: 'My Folder', + displayName: 'My Folder', + }); + }); + + it('calls onCreated with the returned folder on success', async () => { + const folder = { + id: 'folder-1', + name: 'my-folder', + displayName: 'My Folder', + }; + (createFolder as jest.Mock).mockResolvedValue(folder); + + render(); + + fireEvent.change(screen.getByTestId('folder-name-input'), { + target: { value: 'My Folder' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('create-folder-btn')); + }); + + await waitFor(() => + expect(defaultProps.onCreated).toHaveBeenCalledWith(folder) + ); + }); + + it('calls onClose after successful creation', async () => { + const folder = { id: 'folder-1', name: 'my-folder' }; + (createFolder as jest.Mock).mockResolvedValue(folder); + + render(); + + fireEvent.change(screen.getByTestId('folder-name-input'), { + target: { value: 'My Folder' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('create-folder-btn')); + }); + + await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalled()); + }); + + it('resets the input after successful creation', async () => { + const folder = { id: 'folder-1', name: 'my-folder' }; + (createFolder as jest.Mock).mockResolvedValue(folder); + + render(); + + const input = screen.getByTestId('folder-name-input'); + fireEvent.change(input, { target: { value: 'My Folder' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('create-folder-btn')); + }); + + await waitFor(() => expect(input).toHaveValue('')); + }); + + it('does not call createFolder when name is empty', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('create-folder-btn')); + }); + + expect(createFolder).not.toHaveBeenCalled(); + }); + + it('calls onClose when cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByText(/cancel/i)); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when dialog close button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('dialog-close')); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.component.tsx new file mode 100644 index 000000000000..de4a0360aa4e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.component.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ButtonUtility, + Card, + Skeleton, + Tree, + Typography, +} from '@openmetadata/ui-core-components'; +import { Plus, Trash01 } from '@untitledui/icons'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as FolderIcon } from '../../../assets/svg/ic-folder-new.svg'; +import DeleteModal from '../../../components/common/DeleteModal/DeleteModal'; +import { Folder } from '../../../generated/entity/data/folder'; +import { deleteFolder, listFolders } from '../../../rest/assetAPI'; +import { FileTypeLabel } from '../../../utils/ContextCenterUtils'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +import CreateFolderModal from '../CreateFolderModal/CreateFolderModal.component'; +import { DocFile } from './DocumentsView.interface'; + +export interface DocumentFolderViewProps { + files?: DocFile[]; + selectedFolderId?: string; + onSelectFolder: (folderId: string | undefined) => void; + onFoldersLoaded?: (folders: Folder[]) => void; +} + +const DocumentFolderView = ({ + files = [], + selectedFolderId, + onSelectFolder, + onFoldersLoaded, +}: DocumentFolderViewProps) => { + const { t } = useTranslation(); + const [folders, setFolders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [folderToDelete, setFolderToDelete] = useState(); + const [isDeletingFolder, setIsDeletingFolder] = useState(false); + + const fetchFolders = useCallback(async () => { + setIsLoading(true); + try { + const data = await listFolders(); + setFolders(data); + onFoldersLoaded?.(data); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setIsLoading(false); + } + }, [onFoldersLoaded]); + + useEffect(() => { + fetchFolders(); + }, [fetchFolders]); + + const handleFolderCreated = (folder: Folder) => { + const updated = [...folders, folder]; + setFolders(updated); + onFoldersLoaded?.(updated); + }; + + const handleDeleteConfirm = async () => { + if (!folderToDelete) { + return; + } + + try { + setIsDeletingFolder(true); + await deleteFolder(folderToDelete.id); + const updated = folders.filter((f) => f.id !== folderToDelete.id); + setFolders(updated); + onFoldersLoaded?.(updated); + if (selectedFolderId === folderToDelete.id) { + onSelectFolder(undefined); + } + showSuccessToast( + t('server.entity-deleted-successfully', { entity: t('label.folder') }) + ); + setFolderToDelete(undefined); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setFolderToDelete(undefined); + setIsDeletingFolder(false); + } + }; + + const handleFolderItemSelect = (folderId: string) => { + onSelectFolder(selectedFolderId === folderId ? undefined : folderId); + }; + + return ( + <> + +
+
+
+ +
+
+ + {t('label.folder')} + + + + {folders.length} {t('label.folder-plural')} + + + {files.length} {t('label.file-plural')} + + +
+
+ setIsCreateModalOpen(true)} + /> +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( + + {folders.map((folder) => { + const isSelected = selectedFolderId === folder.id; + const folderFiles = files.filter( + (f) => f.folderId === folder.id + ); + + return ( + + +
+ + + { + e.stopPropagation(); + setFolderToDelete(folder); + }} + /> +
+
+ + {folderFiles.map((file) => ( + + + + + {file.name} + + + + ))} +
+ ); + })} +
+ )} +
+
+ + setIsCreateModalOpen(false)} + onCreated={handleFolderCreated} + /> + + {folderToDelete && ( + setFolderToDelete(undefined)} + onDelete={handleDeleteConfirm} + /> + )} + + ); +}; + +export default DocumentFolderView; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.test.tsx new file mode 100644 index 000000000000..652f9ed73f70 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentFolderView.test.tsx @@ -0,0 +1,366 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { deleteFolder, listFolders } from 'rest/assetAPI'; +import DocumentFolderView from './DocumentFolderView.component'; +import { DocFile } from './DocumentsView.interface'; + +jest.mock('rest/assetAPI', () => ({ + listFolders: jest.fn(), + deleteFolder: jest.fn(), +})); + +jest.mock('../CreateFolderModal/CreateFolderModal.component', () => + jest.fn( + ({ + isOpen, + onCreated, + onClose, + }: { + isOpen: boolean; + onCreated: (f: unknown) => void; + onClose: () => void; + }) => + isOpen ? ( +
+ + +
+ ) : null + ) +); + +jest.mock('../../../components/common/DeleteModal/DeleteModal', () => + jest.fn( + ({ + open, + onDelete, + onCancel, + }: { + open: boolean; + onDelete: () => void; + onCancel: () => void; + }) => + open ? ( +
+ + +
+ ) : null + ) +); + +jest.mock('utils/ContextCenterUtils', () => ({ + FileTypeLabel: jest.fn(({ fileType }: { fileType: string }) => ( + {fileType} + )), +})); + +jest.mock('@openmetadata/ui-core-components', () => ({ + ButtonUtility: jest.fn( + ({ + onClick, + 'data-testid': testId, + tooltip, + }: { + onClick?: (e: React.MouseEvent) => void; + 'data-testid'?: string; + tooltip?: string; + }) => ( + + ) + ), + Card: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + Skeleton: jest.fn(() =>
), + Tree: Object.assign( + jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + { + Item: jest.fn( + ({ children, id }: { children: React.ReactNode; id: string }) => ( +
{children}
+ ) + ), + ItemContent: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + } + ), + Typography: jest.fn(({ children }: { children: React.ReactNode }) => ( + {children} + )), +})); + +const mockFolders = [ + { id: 'folder-1', name: 'folder-1', displayName: 'Folder One' }, + { id: 'folder-2', name: 'folder-2', displayName: 'Folder Two' }, +]; + +const mockFiles: DocFile[] = [ + { + id: 'file-1', + name: 'report.pdf', + fileType: 'pdf', + sizeLabel: '1 MB', + folderId: 'folder-1', + }, + { + id: 'file-2', + name: 'data.csv', + fileType: 'other', + sizeLabel: '200 KB', + folderId: 'folder-2', + }, +]; + +describe('DocumentFolderView', () => { + beforeEach(() => { + jest.clearAllMocks(); + (listFolders as jest.Mock).mockResolvedValue(mockFolders); + }); + + it('shows skeletons while loading', () => { + (listFolders as jest.Mock).mockReturnValue(new Promise(() => undefined)); + render(); + + expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0); + }); + + it('renders folder names after loading', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + expect(screen.getByText('Folder Two')).toBeInTheDocument(); + }); + + it('calls onFoldersLoaded with fetched folders', async () => { + const onFoldersLoaded = jest.fn(); + render( + + ); + + await waitFor(() => + expect(onFoldersLoaded).toHaveBeenCalledWith(mockFolders) + ); + }); + + it('renders files as children under their parent folder', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + expect(screen.getByText('report.pdf')).toBeInTheDocument(); + expect(screen.getByText('data.csv')).toBeInTheDocument(); + }); + + it('shows folder and file counts in the subtitle', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + expect(screen.getAllByText(/2/).length).toBeGreaterThanOrEqual(1); + }); + + it('calls onSelectFolder with folderId when a folder is clicked', async () => { + const onSelectFolder = jest.fn(); + render( + + ); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByText('Folder One')); + + expect(onSelectFolder).toHaveBeenCalledWith('folder-1'); + }); + + it('calls onSelectFolder with undefined when the selected folder is clicked again', async () => { + const onSelectFolder = jest.fn(); + render( + + ); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByText('Folder One')); + + expect(onSelectFolder).toHaveBeenCalledWith(undefined); + }); + + it('opens the create folder modal when the add button is clicked', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('add-folder-btn')); + + expect(screen.getByTestId('create-folder-modal')).toBeInTheDocument(); + }); + + it('adds the new folder to the list after creation', async () => { + const onFoldersLoaded = jest.fn(); + render( + + ); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('add-folder-btn')); + fireEvent.click(screen.getByTestId('modal-create-btn')); + + await waitFor(() => + expect(screen.getByText('New Folder')).toBeInTheDocument() + ); + + expect(onFoldersLoaded).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ displayName: 'New Folder' }), + ]) + ); + }); + + it('shows delete modal when delete button is clicked', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + const deleteBtn = screen.getByTestId('delete-folder-btn-folder-1'); + fireEvent.click(deleteBtn); + + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + }); + + it('calls deleteFolder and removes the folder on confirm', async () => { + (deleteFolder as jest.Mock).mockResolvedValue(undefined); + const onFoldersLoaded = jest.fn(); + + render( + + ); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('delete-folder-btn-folder-1')); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete-btn')); + }); + + await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('folder-1')); + await waitFor(() => + expect(screen.queryByText('Folder One')).not.toBeInTheDocument() + ); + }); + + it('calls onSelectFolder(undefined) when the selected folder is deleted', async () => { + (deleteFolder as jest.Mock).mockResolvedValue(undefined); + const onSelectFolder = jest.fn(); + + render( + + ); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('delete-folder-btn-folder-1')); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete-btn')); + }); + + await waitFor(() => expect(onSelectFolder).toHaveBeenCalledWith(undefined)); + }); + + it('closes delete modal on cancel without deleting', async () => { + render(); + + await waitFor(() => + expect(screen.getByText('Folder One')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('delete-folder-btn-folder-1')); + fireEvent.click(screen.getByTestId('cancel-delete-btn')); + + expect(deleteFolder).not.toHaveBeenCalled(); + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.component.tsx index 8b1ddf65ff4a..519c5702ee4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.component.tsx @@ -20,46 +20,71 @@ import { TooltipTrigger, Typography, } from '@openmetadata/ui-core-components'; -import { Download01, Share06, Trash01 } from '@untitledui/icons'; -import { FC } from 'react'; +import { + ChevronRight, + Download01, + Pin02, + Share06, + Trash01, +} from '@untitledui/icons'; +import { AxiosError } from 'axios'; +import { FC, useState } from 'react'; +import { + Menu as AriaMenu, + MenuItem as AriaMenuItem, + Popover as AriaPopover, + SubmenuTrigger, +} from 'react-aria-components'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as FolderIcon } from '../../../assets/svg/ic-folder-new.svg'; import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import { FILE_TYPE_STYLES } from '../../../constants/ContextCenter.constants'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; +import { moveFileToFolder } from '../../../rest/assetAPI'; +import { FileTypeLabel } from '../../../utils/ContextCenterUtils'; import { getShortRelativeTime } from '../../../utils/date-time/DateTimeUtils'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import { DocFile, - DocFileType, DocumentsViewProps, + FolderOption, } from './DocumentsView.interface'; -const FileTypeBadge: FC<{ fileType: DocFileType }> = ({ fileType }) => { - const { bg, label, text } = FILE_TYPE_STYLES[fileType || 'other']; - - return ( - - {label} - - ); -}; - -// ─── Actions dropdown ───────────────────────────────────────────────────────── - interface FileActionsProps { canDelete?: boolean; file: DocFile; + folders?: FolderOption[]; onShareFile?: (file: DocFile) => void; onDeleteFile?: (file: DocFile) => void; + onFileMoved?: (file: DocFile, targetFolderId: string) => void; } const FileActions: FC = ({ canDelete, file, + folders = [], onDeleteFile, + onFileMoved, onShareFile, }) => { const { t } = useTranslation(); + const [isMoving, setIsMoving] = useState(false); + + const availableFolders = folders.filter((f) => f.id !== file.folderId); + + const handleMoveToFolder = async (folderId: string) => { + try { + setIsMoving(true); + await moveFileToFolder(file.driveFileId ?? file.id, folderId); + onFileMoved?.(file, folderId); + showSuccessToast( + t('message.entity-moved-successfully', { entity: t('label.document') }) + ); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setIsMoving(false); + } + }; return ( @@ -69,7 +94,7 @@ const FileActions: FC = ({ - + { if (key === 'share') { @@ -84,6 +109,76 @@ const FileActions: FC = ({ id="share" label={t('label.share-file')} /> + + + + `tw:group tw:block tw:cursor-pointer tw:px-1.5 tw:py-px tw:outline-hidden${ + state.isDisabled ? ' tw:cursor-not-allowed tw:opacity-50' : '' + }` + } + data-testid="move-btn" + isDisabled={isMoving || availableFolders.length === 0}> + {() => ( +
+
+ )} +
+ + + {availableFolders.map((folder) => ( + + `tw:group tw:block tw:cursor-pointer tw:px-1.5 tw:py-px tw:outline-hidden${ + state.isDisabled ? ' tw:cursor-not-allowed' : '' + }` + } + data-testid={`move-to-folder-${folder.id}`} + id={folder.id} + key={folder.id} + textValue={folder.name} + onAction={() => handleMoveToFolder(folder.id)}> + {() => ( +
+ + + {folder.name} + +
+ )} +
+ ))} +
+
+
+ {canDelete && (
@@ -109,26 +204,20 @@ const FileActions: FC = ({ const FileRowSkeleton: FC = () => (
- {/* File type badge */} - - {/* File details */}
-
- - {/* Actions */}
@@ -136,21 +225,23 @@ const FileRowSkeleton: FC = () => (
); -// ─── File row ───────────────────────────────────────────────────────────────── - interface FileRowProps { canDelete?: boolean; file: DocFile; + folders?: FolderOption[]; onDownload?: (file: DocFile) => void; onShareFile?: (file: DocFile) => void; onDeleteFile?: (file: DocFile) => void; + onFileMoved?: (file: DocFile, targetFolderId: string) => void; } const FileRow: FC = ({ canDelete, file, + folders, onDeleteFile, onDownload, + onFileMoved, onShareFile, }) => { const { t } = useTranslation(); @@ -159,7 +250,7 @@ const FileRow: FC = ({
- +
@@ -211,7 +302,9 @@ const FileRow: FC = ({
@@ -222,21 +315,20 @@ const FileRow: FC = ({ const DocumentViewLoading = () => Array.from({ length: 8 }).map((_, idx) => ); -// ─── Main component ─────────────────────────────────────────────────────────── - const DocumentsView: FC = ({ canDelete, data, + folders, isLoading, onDeleteFile, onDownload, + onFileMoved, onShareFile, }) => { return ( - {/* Right: file list */} {data.length > 0 || isLoading ? (
{isLoading ? ( @@ -246,9 +338,11 @@ const DocumentsView: FC = ({ )) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.interface.ts index 989da28b9e22..86ac32adafd2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.interface.ts @@ -15,12 +15,14 @@ export type DocFileType = 'pdf' | 'xls' | 'csv' | 'doc' | 'image' | 'other'; export interface DocFile { id: string; + driveFileId?: string; name: string; fileType: DocFileType; sizeLabel: string; updatedBy?: string; updatedAt?: number; folderId?: string; + folderFqn?: string; } export interface DocFolder { @@ -29,11 +31,18 @@ export interface DocFolder { files: DocFile[]; } +export interface FolderOption { + id: string; + name: string; +} + export interface DocumentsViewProps { canDelete?: boolean; data: DocFile[]; + folders?: FolderOption[]; isLoading: boolean; onDownload?: (file: DocFile) => void; onShareFile?: (file: DocFile) => void; onDeleteFile?: (file: DocFile) => void; + onFileMoved?: (file: DocFile, targetFolderId: string) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.test.tsx index 08c40433b909..78c4e2ce6286 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/DocumentsView/DocumentsView.test.tsx @@ -20,6 +20,37 @@ jest.mock( () => jest.fn(() =>
) ); +jest.mock('react-aria-components', () => ({ + Menu: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + MenuItem: jest.fn( + ({ + children, + onAction, + 'data-testid': testId, + }: { + children: React.ReactNode | (() => React.ReactNode); + onAction?: () => void; + 'data-testid'?: string; + }) => ( +
+ {typeof children === 'function' ? children() : children} +
+ ) + ), + Popover: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + SubmenuTrigger: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), +})); + jest.mock('@openmetadata/ui-core-components', () => ({ ButtonUtility: jest.fn( ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.component.tsx index 64695611c328..7ba6ef5b465e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.component.tsx @@ -25,8 +25,8 @@ import { AlertCircle, Trash01, UploadCloud02 } from '@untitledui/icons'; import { FC, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DOCUMENT_MAX_FILE_SIZE } from '../../../constants/ContextCenter.constants'; -import { Asset } from '../../../generated/attachments/asset'; -import { uploadAsset } from '../../../rest/assetAPI'; +import { ContextFile } from '../../../generated/entity/data/contextFile'; +import { uploadDriveFile } from '../../../rest/assetAPI'; import { QueuedFile, StagedFile, @@ -35,7 +35,7 @@ import { const UploadDocumentModal: FC = ({ isOpen, - entityLink, + folderFqn, onClose, onUploaded, }) => { @@ -44,14 +44,14 @@ const UploadDocumentModal: FC = ({ const [queuedFiles, setQueuedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [sizeError, setSizeError] = useState(''); - const uploadedAssetsRef = useRef([]); + const uploadedFilesRef = useRef([]); const cancelledRef = useRef(false); const hasStartedUploading = queuedFiles.length > 0; const handleClose = () => { cancelledRef.current = true; - uploadedAssetsRef.current = []; + uploadedFilesRef.current = []; setStagedFiles([]); setQueuedFiles([]); setIsUploading(false); @@ -59,7 +59,9 @@ const UploadDocumentModal: FC = ({ onClose(); }; - const uploadSingleFile = async (entry: QueuedFile): Promise => { + const uploadSingleFile = async ( + entry: QueuedFile + ): Promise => { setQueuedFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, progress: 0, status: 'uploading' } : f @@ -67,14 +69,14 @@ const UploadDocumentModal: FC = ({ ); try { - const asset = await uploadAsset(entry.file, entityLink); + const contextFile = await uploadDriveFile(entry.file, folderFqn); setQueuedFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, progress: 100, status: 'done' } : f ) ); - return asset; + return contextFile; } catch { setQueuedFiles((prev) => prev.map((f) => @@ -115,12 +117,12 @@ const UploadDocumentModal: FC = ({ } setIsUploading(true); - const asset = await uploadSingleFile(entry); + const contextFile = await uploadSingleFile(entry); setIsUploading(false); - if (asset) { - uploadedAssetsRef.current = [...uploadedAssetsRef.current, asset]; - onUploaded?.([asset]); + if (contextFile) { + uploadedFilesRef.current = [...uploadedFilesRef.current, contextFile]; + onUploaded?.([contextFile]); } }; @@ -142,23 +144,23 @@ const UploadDocumentModal: FC = ({ setQueuedFiles((prev) => [...prev, ...newQueued]); setIsUploading(true); - const batchAssets: Asset[] = []; + const batchFiles: ContextFile[] = []; for (const entry of newQueued) { if (cancelledRef.current) { break; } - const asset = await uploadSingleFile(entry); - if (asset) { - batchAssets.push(asset); + const contextFile = await uploadSingleFile(entry); + if (contextFile) { + batchFiles.push(contextFile); } } if (!cancelledRef.current) { setIsUploading(false); - if (batchAssets.length > 0) { - onUploaded?.(batchAssets); + if (batchFiles.length > 0) { + onUploaded?.(batchFiles); } } }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.interface.ts index 40e2d8b28176..1f0e7e7230bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.interface.ts @@ -11,13 +11,13 @@ * limitations under the License. */ -import { Asset } from '../../../generated/attachments/asset'; +import { ContextFile } from '../../../generated/entity/data/contextFile'; export interface UploadDocumentModalProps { isOpen: boolean; - entityLink: string; + folderFqn?: string; onClose: () => void; - onUploaded?: (assets: Asset[]) => void; + onUploaded?: (files: ContextFile[]) => void; } export type UploadStatus = 'uploading' | 'done' | 'error'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.test.tsx index 4b39bb4c5516..080f829ca800 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadDocumentModal/UploadDocumentModal.test.tsx @@ -18,11 +18,11 @@ import { screen, waitFor, } from '@testing-library/react'; -import { uploadAsset } from '../../../rest/assetAPI'; +import { uploadDriveFile } from '../../../rest/assetAPI'; import UploadDocumentModal from './UploadDocumentModal.component'; jest.mock('rest/assetAPI', () => ({ - uploadAsset: jest.fn(), + uploadDriveFile: jest.fn(), })); let mockOnDropFiles: ((files: FileList) => void) | undefined; @@ -143,7 +143,7 @@ jest.mock('@openmetadata/ui-core-components', () => ({ const defaultProps = { isOpen: true, - entityLink: 'entity::link', + folderFqn: undefined, onClose: jest.fn(), onUploaded: jest.fn(), }; @@ -264,9 +264,9 @@ describe('UploadDocumentModal', () => { expect(defaultProps.onClose).toHaveBeenCalled(); }); - it('calls uploadAsset and onUploaded when attach is clicked', async () => { + it('calls uploadDriveFile and onUploaded when attach is clicked', async () => { const mockAsset = { id: 'asset-1', name: 'test.pdf' }; - (uploadAsset as jest.Mock).mockResolvedValue(mockAsset); + (uploadDriveFile as jest.Mock).mockResolvedValue(mockAsset); render(); @@ -276,7 +276,7 @@ describe('UploadDocumentModal', () => { fireEvent.click(screen.getByText(/attach-file-plural/i)); - await waitFor(() => expect(uploadAsset).toHaveBeenCalled()); + await waitFor(() => expect(uploadDriveFile).toHaveBeenCalled()); expect( await screen.findByTestId('progress-bar-test.pdf') @@ -296,7 +296,9 @@ describe('UploadDocumentModal', () => { }); it('shows the failed state in the progress bar on upload error', async () => { - (uploadAsset as jest.Mock).mockRejectedValue(new Error('upload failed')); + (uploadDriveFile as jest.Mock).mockRejectedValue( + new Error('upload failed') + ); render(); @@ -312,7 +314,9 @@ describe('UploadDocumentModal', () => { }); it('shows retry button for failed uploads', async () => { - (uploadAsset as jest.Mock).mockRejectedValue(new Error('upload failed')); + (uploadDriveFile as jest.Mock).mockRejectedValue( + new Error('upload failed') + ); render(); @@ -327,7 +331,7 @@ describe('UploadDocumentModal', () => { it('retries a failed upload when the retry button is clicked', async () => { const mockAsset = { id: 'asset-retry', name: 'fail.pdf' }; - (uploadAsset as jest.Mock) + (uploadDriveFile as jest.Mock) .mockRejectedValueOnce(new Error('first attempt failed')) .mockResolvedValueOnce(mockAsset); @@ -342,7 +346,7 @@ describe('UploadDocumentModal', () => { const retryBtn = await screen.findByTestId('retry-fail.pdf'); fireEvent.click(retryBtn); - await waitFor(() => expect(uploadAsset).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(uploadDriveFile).toHaveBeenCalledTimes(2)); await waitFor(() => expect(defaultProps.onUploaded).toHaveBeenCalledWith([mockAsset]) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadedDocumentCard/UploadedDocumentCard.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadedDocumentCard/UploadedDocumentCard.interface.ts index 8196f8c01e79..75846de00aed 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadedDocumentCard/UploadedDocumentCard.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/UploadedDocumentCard/UploadedDocumentCard.interface.ts @@ -17,6 +17,7 @@ export type DocumentProcessingStatus = 'processed' | 'analyzing' | 'failed'; export interface UploadedDocumentItem { id: string; + driveFileId?: string; name: string; fileType: DocumentFileType; sizeLabel: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgeCard/KnowledgeCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgeCard/KnowledgeCard.tsx index a6e5f9b4f686..200178dfc72b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgeCard/KnowledgeCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgeCard/KnowledgeCard.tsx @@ -67,6 +67,7 @@ import { import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; import { deleteKnowledgePage } from '../../../rest/knowledgeCenterAPI'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; +import { getEntityName } from '../../../utils/EntityUtils'; import './knowledge-card.less'; export interface KnowledgeCardProps { @@ -336,7 +337,7 @@ const KnowledgeCard: FC = ({ className="m-b-0 d-block entity-header-display-name text-lg font-semibold cursor-pointer knowledge-card-title text-primary" data-testid="entity-header-display-name" ellipsis={{ tooltip: true }}> - {knowledgePage?.displayName || t('label.untitled')} + {getEntityName(knowledgePage) || t('label.untitled')} {isQuickLink && !readonly && quickLinkActions}
@@ -481,7 +482,7 @@ const KnowledgeCard: FC = ({ onDelete={async () => { setIsDeleting(true); try { - await deleteKnowledgePage(knowledgePage.id, false); + await deleteKnowledgePage(knowledgePage.id, false, true); afterDeleteAction(false); } catch (error) { showErrorToast(error as AxiosError); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx index a57fd57c2adc..43a6859b8d2f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx @@ -38,6 +38,7 @@ import { QueryVoteType } from '../../../components/Database/TableQueries/TableQu import { VotingDataProps } from '../../../components/Entity/Voting/voting.interface'; import { CREATE_PAGE_HASH, + KNOWLEDGE_CENTER_CLASSIFICATION, LONG_DELAY, SHORT_DELAY, } from '../../../constants/constants'; @@ -87,6 +88,7 @@ import { } from '../../../utils/KnowledgePageUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; import { getTagsWithoutTier } from '../../../utils/TableUtils'; +import tagClassBase from '../../../utils/TagClassBase'; import { createTagObject } from '../../../utils/TagsUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; @@ -695,6 +697,14 @@ const KnowledgePageDetailComponent: FC = ({ [tabs, activeTab] ); + useEffect(() => { + tagClassBase.setFilterClassification([]); + + return () => { + tagClassBase.setFilterClassification([KNOWLEDGE_CENTER_CLASSIFICATION]); + }; + }, []); + const pageConfig = useMemo(() => { let rightPanel = null; if ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageListComponent/KnowledgePageListComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageListComponent/KnowledgePageListComponent.tsx index cae1229a121b..b5d4e3445ff0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageListComponent/KnowledgePageListComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageListComponent/KnowledgePageListComponent.tsx @@ -48,6 +48,7 @@ import { getKnowledgePageFields } from '../../../constants/KnowledgeCenter.const import { useLimitStore } from '../../../context/LimitsProvider/useLimitsStore'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../enums/common.enum'; +import { SearchIndex } from '../../../enums/search.enum'; import { Paging } from '../../../generated/type/paging'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; @@ -66,6 +67,7 @@ import { unFollowKnowledgePage, updateKnowledgePageVote, } from '../../../rest/knowledgeCenterAPI'; +import { searchQuery as fetchSearchResults } from '../../../rest/searchAPI'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import { Transi18next } from '../../../utils/i18next/LocalUtil'; import { showErrorToast } from '../../../utils/ToastUtils'; @@ -82,6 +84,7 @@ interface KnowledgePageListComponentProps { permissions: OperationPermission; hideAddButton?: boolean; rightPanelSlot?: React.ReactNode; + searchQuery?: string; } const KnowledgePageListComponent = forwardRef< @@ -89,7 +92,13 @@ const KnowledgePageListComponent = forwardRef< KnowledgePageListComponentProps >( ( - { onPageChange, permissions, hideAddButton = false, rightPanelSlot }, + { + onPageChange, + permissions, + hideAddButton = false, + rightPanelSlot, + searchQuery, + }, ref ) => { const { currentUser, theme } = useApplicationStore(); @@ -101,6 +110,7 @@ const KnowledgePageListComponent = forwardRef< const [isLoadingMore, setIsLoadingMore] = useState(false); const [knowledgePages, setKnowledgePages] = useState([]); const [paging, setPaging] = useState({ total: 0 }); + const [pageOffset, setPageOffset] = useState(0); const [isCreatingNewPage, setIsCreatingNewPage] = useState(false); const [showAddLinkModal, setShowAddLinkModal] = useState(false); const { getResourceLimit } = useLimitStore(); @@ -116,22 +126,42 @@ const KnowledgePageListComponent = forwardRef< const handleRefreshTagsCategory = (value: boolean) => setRefreshTagsCategory(value); - const fetchKnowledgePages = async (after?: string) => { - if (after) { + const fetchKnowledgePages = async (offset = 0) => { + if (offset > 0) { setIsLoadingMore(true); } else { setIsLoading(true); } try { - const { data, paging: pagingObj } = await getListKnowledgePages({ - fields: getKnowledgePageFields(), - after, - limit: PAGE_SIZE_MEDIUM, - }); - setKnowledgePages((prev) => - uniqBy(after ? [...prev, ...data] : data, 'id') - ); - setPaging(pagingObj); + if (searchQuery) { + const results = await fetchSearchResults({ + query: searchQuery, + searchIndex: SearchIndex.KNOWLEDGE_PAGE_INDEX, + queryFilter: { + query: { term: { pageType: PageType.ARTICLE } }, + }, + sortField: 'updatedAt', + sortOrder: 'desc', + pageSize: PAGE_SIZE_MEDIUM, + }); + setKnowledgePages( + results.hits.hits.map((hit) => hit._source as KnowledgePage) + ); + setPaging({ total: results.hits.total.value }); + } else { + const { data, paging: pagingObj } = await getListKnowledgePages({ + fields: getKnowledgePageFields(), + limit: PAGE_SIZE_MEDIUM, + offset, + pageType: PageType.ARTICLE, + sortBy: 'updatedAt', + sortOrder: 'desc', + }); + setKnowledgePages((prev) => + uniqBy(offset > 0 ? [...prev, ...data] : data, 'id') + ); + setPaging(pagingObj); + } } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -299,21 +329,34 @@ const KnowledgePageListComponent = forwardRef< useEffect(() => { if (hasViewPermission) { - fetchKnowledgePages(); + setPageOffset(0); + fetchKnowledgePages(0); } else { setIsLoading(false); } - }, [hasViewPermission]); + }, [hasViewPermission, searchQuery]); - /** - * Handle infinite scrolling - */ useEffect(() => { - const after = paging.after; - if (isInView && after && !isLoadingMore && hasViewPermission) { - fetchKnowledgePages(after); + const hasMore = knowledgePages.length < paging.total; + if ( + isInView && + hasMore && + !isLoadingMore && + !searchQuery && + hasViewPermission + ) { + const nextOffset = pageOffset + PAGE_SIZE_MEDIUM; + setPageOffset(nextOffset); + fetchKnowledgePages(nextOffset); } - }, [isInView, paging, isLoadingMore, hasViewPermission]); + }, [ + isInView, + paging.total, + knowledgePages.length, + isLoadingMore, + searchQuery, + hasViewPermission, + ]); const items: MenuProps['items'] = [ { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePagesHierarchy/KnowledgePagesHierarchy.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePagesHierarchy/KnowledgePagesHierarchy.tsx index 4b00a5f7cb9b..8673b20eedff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePagesHierarchy/KnowledgePagesHierarchy.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePagesHierarchy/KnowledgePagesHierarchy.tsx @@ -21,7 +21,6 @@ import { DataNode } from 'antd/es/tree'; import { AntTreeNodeProps, DirectoryTreeProps, TreeProps } from 'antd/lib/tree'; import { AxiosError } from 'axios'; import { ReactComponent as KnowledgeCenterIcon } from '../../../assets/svg/ic-knowledge-page.svg'; -import { CREATE_PAGE_HASH } from '../../../constants/constants'; import { CreateKnowledgePage, KnowledgePage, @@ -68,7 +67,10 @@ import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg' import DeleteModal from '../../../components/common/DeleteModal/DeleteModal'; import CreateErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/CreateErrorPlaceHolder'; import Loader from '../../../components/common/Loader/Loader'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; +import { + CREATE_PAGE_HASH, + DE_ACTIVE_COLOR, +} from '../../../constants/constants'; import { KNOWLEDGE_CENTER_INSTANCE_NAME_LENGTH, KNOWLEDGE_CENTER_PAGINATION_LIMIT, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/QuickLinkFormModal/QuickLinkFormModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/QuickLinkFormModal/QuickLinkFormModal.tsx index c9511917b307..128ee2a22c12 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/QuickLinkFormModal/QuickLinkFormModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/QuickLinkFormModal/QuickLinkFormModal.tsx @@ -20,10 +20,11 @@ import { Form } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { cloneDeep, isEqual, isNil, isUndefined } from 'lodash'; -import { FC, useMemo, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import DataAssetAsyncSelectList from '../../../components/DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList'; import { DataAssetOption } from '../../../components/DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList.interface'; +import { KNOWLEDGE_CENTER_CLASSIFICATION } from '../../../constants/constants'; import { getKnowledgePageFields } from '../../../constants/KnowledgeCenter.constant'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { EntityReference } from '../../../generated/entity/type'; @@ -46,6 +47,7 @@ import { import i18n from '../../../utils/i18next/LocalUtil'; import { getFilterTags } from '../../../utils/TableTags/TableTags.utils'; import { getTagsWithoutTier } from '../../../utils/TableUtils'; +import tagClassBase from '../../../utils/TagClassBase'; import { showErrorToast } from '../../../utils/ToastUtils'; export interface QuickLinkFormModalFormData @@ -76,6 +78,18 @@ export const QuickLinkFormModal: FC = ({ const [isUpdating, setIsUpdating] = useState(false); + useEffect(() => { + if (isOpen) { + tagClassBase.setFilterClassification([]); + } else { + tagClassBase.setFilterClassification([KNOWLEDGE_CENTER_CLASSIFICATION]); + } + + return () => { + tagClassBase.setFilterClassification([KNOWLEDGE_CENTER_CLASSIFICATION]); + }; + }, [isOpen]); + const { initialValues, initialDataAssetsOptions, restRelatedDataAssets } = useMemo(() => { if (isUndefined(quickLink)) { diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts index b8966519d5c8..b15ebbaa69cd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Cube01, File06, Lightbulb03 } from '@untitledui/icons'; +import { Archive, Cube01, File06, Lightbulb03 } from '@untitledui/icons'; import { ReactComponent as GovernIcon } from '../assets/svg/bank.svg'; import { ReactComponent as ClassificationIcon } from '../assets/svg/classification.svg'; import { ReactComponent as DataQualityRulesIcon } from '../assets/svg/data-observability/data-quality-rules.svg'; @@ -243,21 +243,13 @@ export const SIDEBAR_LIST: Array = [ icon: createIconWithStroke(Lightbulb03 as UntitledIconType, 1.2), dataTestId: `app-bar-item-${SidebarItem.MEMORIES}`, }, - // TODO: In progress - // { - // key: ROUTES.CONTEXT_CENTER_INTEGRATIONS, - // title: 'label.integration-plural', - // redirect_url: ROUTES.CONTEXT_CENTER_INTEGRATIONS, - // icon: IntegrationIcon, - // dataTestId: `app-bar-item-context-center-integrations`, - // }, - // { - // key: ROUTES.CONTEXT_CENTER_ARCHIVE, - // title: 'label.archive', - // redirect_url: ROUTES.CONTEXT_CENTER_ARCHIVE, - // icon: ContextCenterArchiveIcon, - // dataTestId: `app-bar-item-context-center-archive`, - // }, + { + key: ROUTES.CONTEXT_CENTER_ARCHIVE, + title: 'label.archive', + redirect_url: ROUTES.CONTEXT_CENTER_ARCHIVE, + icon: createIconWithStroke(Archive as UntitledIconType, 1.2), + dataTestId: `app-bar-item-context-center-archive`, + }, ], }, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts index 18c6545fd221..4dc7d131ba92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts @@ -57,5 +57,6 @@ export enum SearchIndex { SPREADSHEET = 'spreadsheet', WORKSHEET = 'worksheet', KNOWLEDGE_PAGE_INDEX = 'page', + DRIVE_FILE = 'contextFile', MARKETPLACE = 'marketplace', } diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts index d06ac1d3c6f4..58c143e14d36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts @@ -20,6 +20,7 @@ import { APICollection } from '../generated/entity/data/apiCollection'; import { APIEndpoint } from '../generated/entity/data/apiEndpoint'; import { Chart } from '../generated/entity/data/chart'; import { Container } from '../generated/entity/data/container'; +import { ContextFile } from '../generated/entity/data/contextFile'; import { Dashboard } from '../generated/entity/data/dashboard'; import { DashboardDataModel } from '../generated/entity/data/dashboardDataModel'; import { @@ -243,6 +244,8 @@ export interface KnowledgePageSearchSource extends SearchSourceBase, KnowledgePage {} +export interface DriveFileSearchSource extends SearchSourceBase, ContextFile {} + export type ExploreSearchSource = | TableSearchSource | DashboardSearchSource @@ -323,6 +326,7 @@ export type SearchIndexSearchSourceMapping = { [SearchIndex.WORKSHEET]: WorksheetSearchSource; [SearchIndex.COLUMN]: TableColumnSearchSource; [SearchIndex.KNOWLEDGE_PAGE_INDEX]: KnowledgePageSearchSource; + [SearchIndex.DRIVE_FILE]: DriveFileSearchSource; [SearchIndex.MARKETPLACE]: DataProductSearchSource | DomainSearchSource; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 60bcad4dc33d..7a81e9aa124c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -159,7 +159,10 @@ "approver-plural": "الموافقون", "april": "أبريل", "archive": "أرشيف", + "archive-file-plural": "ملفات الأرشيف", + "archive-plural": "أرشيف", "archived": "مؤرشف", + "archived-by": "مؤرشف بواسطة {{name}}", "argument-plural": "وسائط", "arrow-symbol": "→", "article": "مقال", @@ -443,6 +446,7 @@ "create-entity": "إنشاء {{entity}}", "create-lowercase": "إنشاء", "create-new-bundle-suite": "إنشاء مجموعة حزمة جديدة", + "create-new-folder": "إنشاء مجلد جديد", "create-new-test-suite": "إنشاء مجموعة اختبار جديدة", "create-new-workflow": "إنشاء سير عمل جديد", "create-widget": "إنشاء أداة", @@ -931,6 +935,8 @@ "focus-home": "التركيز على الصفحة الرئيسية", "focus-on-node": "التركيز على العقدة", "focus-selected": "التركيز على المحدد", + "folder": "مجلد", + "folder-plural": "مجلدات", "follow": "متابعة", "followed-lowercase": "تمت المتابعة", "follower-plural": "متابعون", @@ -1371,6 +1377,7 @@ "move": "نقل", "move-anyway": "نقل على أي حال", "move-the-entity": "نقل {{entity}}", + "move-to-folder": "نقل إلى المجلد", "ms": "مللي ثانية", "ms-team-plural": "فرق MS Teams", "multi-select": "تحديد متعدد", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "لا يوجد {{entity}}", "no-entity-available": "لا يتوفر {{entity}}", "no-entity-selected": "لم يتم تحديد {{entity}}", + "no-folder": "لا يوجد مجلد", "no-knowledge-articles-available": "لا توجد مقالات معرفة متاحة", "no-kpis-yet": "ابدأ بتتبع ما يهم", "no-matching-data-asset": "لم يتم العثور على أصول بيانات مطابقة", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 65367f362c90..9de31679c411 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -159,7 +159,10 @@ "approver-plural": "Genehmiger", "april": "April", "archive": "Archiv", + "archive-file-plural": "Archivdateien", + "archive-plural": "Archive", "archived": "Archiviert", + "archived-by": "Archiviert von {{name}}", "argument-plural": "Argumente", "arrow-symbol": "→", "article": "Artikel", @@ -443,6 +446,7 @@ "create-entity": "{{entity}} erstellen", "create-lowercase": "erstellen", "create-new-bundle-suite": "Neue Bundle-Suite erstellen", + "create-new-folder": "Neuen Ordner erstellen", "create-new-test-suite": "Neuen Testsuite erstellen", "create-new-workflow": "Neuen Workflow erstellen", "create-widget": "Widget erstellen", @@ -931,6 +935,8 @@ "focus-home": "Zur Startansicht", "focus-on-node": "Auf Knoten fokussieren", "focus-selected": "Auswahl fokussieren", + "folder": "Ordner", + "folder-plural": "Ordner", "follow": "Folgen", "followed-lowercase": "gefolgt", "follower-plural": "Follower", @@ -1371,6 +1377,7 @@ "move": "Bewegen", "move-anyway": "Trotzdem verschieben", "move-the-entity": "{{entity}} verschieben", + "move-to-folder": "In Ordner verschieben", "ms": "Millisekunden", "ms-team-plural": "MS-Teams", "multi-select": "Mehrfachauswahl", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Kein {{entity}} zugewiesen", "no-entity-available": "Keine {{entity}} sind verfügbar", "no-entity-selected": "Keine {{entity}} ausgewählt", + "no-folder": "Kein Ordner", "no-knowledge-articles-available": "Keine Wissensartikel verfügbar", "no-kpis-yet": "Beginnen Sie damit, das Wichtige zu verfolgen", "no-matching-data-asset": "Keine passenden Datenanlagen gefunden", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 64ea4019d6a9..116afb7a9f0a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -159,7 +159,10 @@ "approver-plural": "Approvers", "april": "April", "archive": "Archive", + "archive-file-plural": "Archive Files", + "archive-plural": "Archives", "archived": "Archived", + "archived-by": "Archived by {{name}}", "argument-plural": "Arguments", "arrow-symbol": "→", "article": "Article", @@ -443,6 +446,7 @@ "create-entity": "Create {{entity}}", "create-lowercase": "create", "create-new-bundle-suite": "Create new Bundle Suite", + "create-new-folder": "Create New Folder", "create-new-test-suite": "Create new test suite", "create-new-workflow": "Create New Workflow", "create-widget": "Create Widget", @@ -931,6 +935,8 @@ "focus-home": "Focus Home", "focus-on-node": "Focus on Node", "focus-selected": "Focus Selected", + "folder": "Folder", + "folder-plural": "Folders", "follow": "Follow", "followed-lowercase": "followed", "follower-plural": "Followers", @@ -1371,6 +1377,7 @@ "move": "Move", "move-anyway": "Move Anyway", "move-the-entity": "Move the {{entity}}", + "move-to-folder": "Move to Folder", "ms": "Milliseconds", "ms-team-plural": "MS Teams", "multi-select": "Multi Select", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "No {{entity}} assigned", "no-entity-available": "No {{entity}} are available", "no-entity-selected": "No {{entity}} Selected", + "no-folder": "No Folder", "no-knowledge-articles-available": "No Knowledge Articles Available", "no-kpis-yet": "Start Tracking What Matters", "no-matching-data-asset": "No matching data assets found", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 4663bccc192d..c4e17e59b661 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -159,7 +159,10 @@ "approver-plural": "Aprobadores", "april": "Abril", "archive": "Archivo", + "archive-file-plural": "Archivos", + "archive-plural": "Archivos", "archived": "Archivado", + "archived-by": "Archivado por {{name}}", "argument-plural": "Argumentos", "arrow-symbol": "→", "article": "Artículo", @@ -443,6 +446,7 @@ "create-entity": "Crear {{entity}}", "create-lowercase": "crear", "create-new-bundle-suite": "Crear nueva Bundle Suite", + "create-new-folder": "Crear nueva carpeta", "create-new-test-suite": "Crear nueva suite de tests", "create-new-workflow": "Crear nuevo flujo de trabajo", "create-widget": "Crear Widget", @@ -931,6 +935,8 @@ "focus-home": "Ir al Inicio", "focus-on-node": "Enfocar en Nodo", "focus-selected": "Enfocar Selección", + "folder": "Carpeta", + "folder-plural": "Carpetas", "follow": "Seguir", "followed-lowercase": "seguido", "follower-plural": "Seguidores", @@ -1371,6 +1377,7 @@ "move": "Mover", "move-anyway": "Mover de todos modos", "move-the-entity": "Mover la {{entity}}", + "move-to-folder": "Mover a la carpeta", "ms": "Milisegundos", "ms-team-plural": "Equipos de MS", "multi-select": "Selector Múltiple", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "No {{entity}} asignado", "no-entity-available": "No hay {{entity}} disponibles", "no-entity-selected": "No se ha seleccionado {{entity}}", + "no-folder": "Sin carpeta", "no-knowledge-articles-available": "No hay artículos de conocimiento disponibles", "no-kpis-yet": "Empieza a rastrear lo que importa", "no-matching-data-asset": "No se encontraron activos de datos coincidentes", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 86cc8257a028..2eaf6aad2fe5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -159,7 +159,10 @@ "approver-plural": "Approbateurs", "april": "Avril", "archive": "Archive", + "archive-file-plural": "Fichiers d'archive", + "archive-plural": "Archives", "archived": "Archivé", + "archived-by": "Archivé par {{name}}", "argument-plural": "Arguments", "arrow-symbol": "→", "article": "Article", @@ -443,6 +446,7 @@ "create-entity": "Créer {{entity}}", "create-lowercase": "créer", "create-new-bundle-suite": "Créer une nouvelle Suite de Bundle", + "create-new-folder": "Créer un nouveau dossier", "create-new-test-suite": "Créer un Nouvel Ensemble de Tests", "create-new-workflow": "Créer un nouveau workflow", "create-widget": "Créer un Widget", @@ -931,6 +935,8 @@ "focus-home": "Retour au Centre", "focus-on-node": "Centrer sur le Noeud", "focus-selected": "Centrer la Sélection", + "folder": "Dossier", + "folder-plural": "Dossiers", "follow": "Suivre", "followed-lowercase": "suivi·e", "follower-plural": "Suiveurs", @@ -1371,6 +1377,7 @@ "move": "Déplacer", "move-anyway": "Déplacer quand même", "move-the-entity": "Déplacer {{entity}}", + "move-to-folder": "Déplacer vers le dossier", "ms": "Millisecondes", "ms-team-plural": "Équipes MS", "multi-select": "Sélection multiple", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Aucun {{entity}} attribué", "no-entity-available": "Aucun {{entity}} disponible", "no-entity-selected": "Aucun {{entity}} Sélectionné", + "no-folder": "Pas de dossier", "no-knowledge-articles-available": "Aucun article de connaissances disponible", "no-kpis-yet": "Commencez à suivre ce qui compte", "no-matching-data-asset": "Aucun actif de données trouvé", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 0a629e07c867..9540e36e10a6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -159,7 +159,10 @@ "approver-plural": "Aprobadores", "april": "Abril", "archive": "Arquivo", + "archive-file-plural": "Ficheiros de arquivo", + "archive-plural": "Arquivos", "archived": "Arquivado", + "archived-by": "Arquivado por {{name}}", "argument-plural": "Argumentos", "arrow-symbol": "→", "article": "Artigo", @@ -443,6 +446,7 @@ "create-entity": "Crear {{entity}}", "create-lowercase": "crear", "create-new-bundle-suite": "Crear nova Suite de Bundle", + "create-new-folder": "Crear novo cartafol", "create-new-test-suite": "Crear un novo conxunto de probas", "create-new-workflow": "Crear novo fluxo de traballo", "create-widget": "Crear Widget", @@ -931,6 +935,8 @@ "focus-home": "Ir ao Inicio", "focus-on-node": "Enfocar no Nodo", "focus-selected": "Enfocar Selección", + "folder": "Cartafol", + "folder-plural": "Cartafoles", "follow": "Seguir", "followed-lowercase": "seguido", "follower-plural": "Seguidores", @@ -1371,6 +1377,7 @@ "move": "Mover", "move-anyway": "Mover de todos os xeitos", "move-the-entity": "Mover o {{entity}}", + "move-to-folder": "Mover ao cartafol", "ms": "Milisegundos", "ms-team-plural": "Equipos MS", "multi-select": "Selección múltiple", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Non se asignou ningún {{entity}}", "no-entity-available": "Non {{entity}} están dispoñibles", "no-entity-selected": "Non hai {{entity}} seleccionado", + "no-folder": "Sen cartafol", "no-knowledge-articles-available": "Non hai artigos de coñecemento dispoñibles", "no-kpis-yet": "Comeza a rastrexar o que importa", "no-matching-data-asset": "Non se atoparon activos de datos coincidentes", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 6e5da5ec5e63..e445bb4b01e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -159,7 +159,10 @@ "approver-plural": "מאשרים", "april": "אפריל", "archive": "ארכיון", + "archive-file-plural": "קבצי ארכיון", + "archive-plural": "ארכיונים", "archived": "בארכיון", + "archived-by": "בארכיון על ידי {{name}}", "argument-plural": "ארגומנטים", "arrow-symbol": "→", "article": "מאמר", @@ -443,6 +446,7 @@ "create-entity": "צור {{entity}}", "create-lowercase": "ליצור", "create-new-bundle-suite": "צור Bundle Suite חדש", + "create-new-folder": "צור תיקייה חדשה", "create-new-test-suite": "צור מערכת בדיקה חדשה", "create-new-workflow": "יצירת תהליך עבודה חדש", "create-widget": "צור ווידג'ט", @@ -931,6 +935,8 @@ "focus-home": "מיקוד על דף הבית", "focus-on-node": "התמקד בצומת", "focus-selected": "מיקוד על הנבחר", + "folder": "תיקייה", + "folder-plural": "תיקיות", "follow": "עקוב", "followed-lowercase": "עקוב", "follower-plural": "עוקבים", @@ -1371,6 +1377,7 @@ "move": "העבר", "move-anyway": "העבר בכל זאת", "move-the-entity": "העבר את {{entity}}", + "move-to-folder": "העבר לתיקייה", "ms": "מילי-שנייה", "ms-team-plural": "צוותי MS", "multi-select": "בחירה מרובה", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "לא הוקצה {{entity}}", "no-entity-available": "אין {{entity}} זמינים", "no-entity-selected": "לא נבחר {{entity}}", + "no-folder": "אין תיקייה", "no-knowledge-articles-available": "אין מאמרי ידע זמינים", "no-kpis-yet": "התחל לעקוב אחר מה שחשוב", "no-matching-data-asset": "לא נמצאו נכסי נתונים תואמים", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 5baeccd65f88..99d0ae79be2b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -159,7 +159,10 @@ "approver-plural": "承認者", "april": "4月", "archive": "アーカイブ", + "archive-file-plural": "アーカイブファイル", + "archive-plural": "アーカイブ", "archived": "アーカイブ済み", + "archived-by": "{{name}} によってアーカイブされました", "argument-plural": "引数", "arrow-symbol": "→", "article": "記事", @@ -443,6 +446,7 @@ "create-entity": "{{entity}}を作成", "create-lowercase": "作成", "create-new-bundle-suite": "新しいBundle Suiteを作成", + "create-new-folder": "新しいフォルダを作成", "create-new-test-suite": "新しいテストスイートを作成", "create-new-workflow": "新規ワークフローを作成", "create-widget": "ウィジェットを作成", @@ -931,6 +935,8 @@ "focus-home": "ホームにフォーカス", "focus-on-node": "ノードにフォーカス", "focus-selected": "選択項目にフォーカス", + "folder": "フォルダー", + "folder-plural": "フォルダー", "follow": "フォロー", "followed-lowercase": "フォロー中", "follower-plural": "フォロワー", @@ -1371,6 +1377,7 @@ "move": "移動", "move-anyway": "それでも移動", "move-the-entity": "{{entity}} を移動", + "move-to-folder": "フォルダーへ移動", "ms": "ミリ秒", "ms-team-plural": "MSチーム", "multi-select": "複数選択", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "{{entity}} が割り当てられていません", "no-entity-available": "利用可能な{{entity}}がありません", "no-entity-selected": "{{entity}}が選択されていません", + "no-folder": "フォルダーなし", "no-knowledge-articles-available": "利用可能なナレッジ記事がありません", "no-kpis-yet": "KPIの追跡を始めましょう", "no-matching-data-asset": "一致するデータアセットはありません", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index b42ee849da82..0026d0b0783e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -159,7 +159,10 @@ "approver-plural": "승인자", "april": "4월", "archive": "아카이브", + "archive-file-plural": "아카이브 파일", + "archive-plural": "아카이브", "archived": "보관됨", + "archived-by": "{{name}} 님이 아카이브함", "argument-plural": "인자들", "arrow-symbol": "→", "article": "기사", @@ -443,6 +446,7 @@ "create-entity": "{{entity}} 생성", "create-lowercase": "생성", "create-new-bundle-suite": "새 Bundle Suite 만들기", + "create-new-folder": "새 폴더 만들기", "create-new-test-suite": "새 테스트 스위트 생성", "create-new-workflow": "새 워크플로 만들기", "create-widget": "위젯 생성", @@ -931,6 +935,8 @@ "focus-home": "홈으로 포커스", "focus-on-node": "노드에 집중", "focus-selected": "선택 항목 포커스", + "folder": "폴더", + "folder-plural": "폴더", "follow": "팔로우", "followed-lowercase": "팔로우됨", "follower-plural": "팔로워들", @@ -1371,6 +1377,7 @@ "move": "이동", "move-anyway": "그래도 이동", "move-the-entity": "{{entity}} 이동", + "move-to-folder": "폴더로 이동", "ms": "밀리초", "ms-team-plural": "MS 팀들", "multi-select": "다중 선택", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "{{entity}}이 할당되지 않았습니다.", "no-entity-available": "사용 가능한 {{entity}}가 없습니다", "no-entity-selected": "선택된 {{entity}} 없음", + "no-folder": "폴더 없음", "no-knowledge-articles-available": "사용 가능한 지식 문서가 없습니다", "no-kpis-yet": "중요한 항목 추적을 시작하세요", "no-matching-data-asset": "일치하는 데이터 자산을 찾을 수 없습니다", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 7f209d33dff2..168f225173d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -159,7 +159,10 @@ "approver-plural": "मंजूरीदार", "april": "एप्रिल", "archive": "संग्रहण", + "archive-file-plural": "संग्रहण फायली", + "archive-plural": "संग्रह", "archived": "संग्रहित", + "archived-by": "{{name}} द्वारे संग्रहित", "argument-plural": "आर्ग्युमेंट्स", "arrow-symbol": "→", "article": "लेख", @@ -443,6 +446,7 @@ "create-entity": "{{entity}} तयार करा", "create-lowercase": "तयार करा", "create-new-bundle-suite": "नवीन बंडल सूट तयार करा", + "create-new-folder": "नवीन फोल्डर तयार करा", "create-new-test-suite": "नवीन चाचणी संच तयार करा", "create-new-workflow": "नवीन वर्कफ्लो तयार करा", "create-widget": "विजेट तयार करा", @@ -931,6 +935,8 @@ "focus-home": "मुख्यपृष्ठावर लक्ष केंद्रित करा", "focus-on-node": "नोडवर लक्ष केंद्रित करा", "focus-selected": "निवडलेल्यावर लक्ष केंद्रित करा", + "folder": "फोल्डर", + "folder-plural": "फोल्डर", "follow": "अनुसरण करा", "followed-lowercase": "अनुसरण केले", "follower-plural": "अनुयायी", @@ -1371,6 +1377,7 @@ "move": "हलवा", "move-anyway": "तरीही हलवा", "move-the-entity": "{{entity}} हलवा", + "move-to-folder": "फोल्डरमध्ये हलवा", "ms": "मिलीसेकंद", "ms-team-plural": "एमएस टीम्स", "multi-select": "मल्टी सिलेक्ट", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "कोणतेही {{entity}} नियुक्त केलेले नाही", "no-entity-available": "कोणतेही {{entity}} उपलब्ध नाहीत", "no-entity-selected": "कोणतेही {{entity}} निवडलेले नाही", + "no-folder": "फोल्डर नाही", "no-knowledge-articles-available": "कोणतेही ज्ञान लेख उपलब्ध नाहीत", "no-kpis-yet": "महत्त्वाच्या गोष्टींचे ट्रॅकिंग सुरू करा", "no-matching-data-asset": "जुळणारी डेटा ॲसेट सापडली नाही", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 75575b2ca169..c00202bf7740 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -159,7 +159,10 @@ "approver-plural": "Goedkeurders", "april": "April", "archive": "Archief", + "archive-file-plural": "Archiefbestanden", + "archive-plural": "Archieven", "archived": "Gearchiveerd", + "archived-by": "Gearchiveerd door {{name}}", "argument-plural": "Argumenten", "arrow-symbol": "→", "article": "Artikel", @@ -443,6 +446,7 @@ "create-entity": "{{entity}} maken", "create-lowercase": "maken", "create-new-bundle-suite": "Nieuwe Bundle Suite maken", + "create-new-folder": "Nieuwe map aanmaken", "create-new-test-suite": "Nieuwe testsuite maken", "create-new-workflow": "Nieuwe workflow maken", "create-widget": "Widget maken", @@ -931,6 +935,8 @@ "focus-home": "Naar Startweergave", "focus-on-node": "Focus op Knooppunt", "focus-selected": "Selectie Focussen", + "folder": "Map", + "folder-plural": "Mappen", "follow": "Volgen", "followed-lowercase": "gevolgd", "follower-plural": "Volgers", @@ -1371,6 +1377,7 @@ "move": "Verplaatsen", "move-anyway": "Toch verplaatsen", "move-the-entity": "Verplaats de {{entity}}", + "move-to-folder": "Verplaatsen naar map", "ms": "Milliseconden", "ms-team-plural": "MS-teams", "multi-select": "Meervoudige selectie", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Geen {{entity}} toegewezen", "no-entity-available": "Er zijn geen {{entity}} beschikbaar", "no-entity-selected": "Geen {{entity}} geselecteerd", + "no-folder": "Geen map", "no-knowledge-articles-available": "Geen kennisartikelen beschikbaar", "no-kpis-yet": "Begin met het bijhouden van wat belangrijk is", "no-matching-data-asset": "Geen overeenkomende data-assets gevonden", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index fe3d92a22909..bab34b6e04ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -159,7 +159,10 @@ "approver-plural": "تأییدکنندگان", "april": "آوریل", "archive": "Arquive", + "archive-file-plural": "فایل‌های بایگانی", + "archive-plural": "بایگانی‌ها", "archived": "بایگانی شده", + "archived-by": "بایگانی شده توسط {{name}}", "argument-plural": "آرگومان‌ها", "arrow-symbol": "→", "article": "مقاله", @@ -443,6 +446,7 @@ "create-entity": "ایجاد {{entity}}", "create-lowercase": "ایجاد", "create-new-bundle-suite": "ایجاد مجموعه باندل جدید", + "create-new-folder": "Criar nova pasta", "create-new-test-suite": "ایجاد مجموعه تست جدید", "create-new-workflow": "ایجاد جریان کاری جدید", "create-widget": "ایجاد ویجت", @@ -931,6 +935,8 @@ "focus-home": "تمرکز بر صفحه اصلی", "focus-on-node": "تمرکز بر گره", "focus-selected": "تمرکز بر انتخاب شده", + "folder": "پوشه", + "folder-plural": "پوشه ها", "follow": "دنبال کردن", "followed-lowercase": "دنبال شده", "follower-plural": "دنبال‌کنندگان", @@ -1371,6 +1377,7 @@ "move": "Mover", "move-anyway": "همچنان جابجا کن", "move-the-entity": "انتقال {{entity}}", + "move-to-folder": "انتقال به پوشه", "ms": "میلی‌ثانیه", "ms-team-plural": "تیم‌های MS", "multi-select": "چند انتخابی", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "هیچ {{entity}} اختصاص داده نشده است", "no-entity-available": "هیچ {{entity}} در دسترس نیست", "no-entity-selected": "هیچ {{entity}} انتخاب نشده", + "no-folder": "بدون پوشه", "no-knowledge-articles-available": "هیچ مقاله دانشی موجود نیست", "no-kpis-yet": "شروع به پیگیری موارد بااهمیت کنید", "no-matching-data-asset": "هیچ دارایی داده‌ی منطبق یافت نشد", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 35e274457c7f..dff9c615e722 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -159,7 +159,10 @@ "approver-plural": "Aprovadores", "april": "Abril", "archive": "Arquivo", + "archive-file-plural": "Arquivos de arquivo", + "archive-plural": "Arquivos", "archived": "Arquivado", + "archived-by": "Arquivado por {{name}}", "argument-plural": "Argumentos", "arrow-symbol": "→", "article": "Artigo", @@ -443,6 +446,7 @@ "create-entity": "Criar {{entity}}", "create-lowercase": "criar", "create-new-bundle-suite": "Criar novo Bundle Suite", + "create-new-folder": "Criar nova pasta", "create-new-test-suite": "Criar novo conjunto de teste", "create-new-workflow": "Criar novo fluxo de trabalho", "create-widget": "Criar widget", @@ -931,6 +935,8 @@ "focus-home": "Ir para Início", "focus-on-node": "Focar no Nó", "focus-selected": "Focar na Seleção", + "folder": "Pasta", + "folder-plural": "Pastas", "follow": "Seguir", "followed-lowercase": "seguido", "follower-plural": "Seguidores", @@ -1371,6 +1377,7 @@ "move": "Mover", "move-anyway": "Mover mesmo assim", "move-the-entity": "Mover a {{entity}}", + "move-to-folder": "Mover para a pasta", "ms": "Milissegundos", "ms-team-plural": "Equipes MS", "multi-select": "Seleção múltipla", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Nenhum {{entity}} atribuído", "no-entity-available": "Nenhum {{entity}} disponível", "no-entity-selected": "Nenhum {{entity}} selecionado", + "no-folder": "Nenhuma pasta", "no-knowledge-articles-available": "Nenhum artigo de conhecimento disponível", "no-kpis-yet": "Comece a acompanhar o que importa", "no-matching-data-asset": "Nenhum ativo de dados correspondente encontrado", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index cbc6ab56ebf5..d59a37f540b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -159,7 +159,10 @@ "approver-plural": "Aprovadores", "april": "Abril", "archive": "Arquivo", + "archive-file-plural": "Ficheiros de Arquivo", + "archive-plural": "Arquivos", "archived": "Arquivado", + "archived-by": "Arquivado por {{name}}", "argument-plural": "Argumentos", "arrow-symbol": "→", "article": "Artigo", @@ -443,6 +446,7 @@ "create-entity": "Criar {{entity}}", "create-lowercase": "criar", "create-new-bundle-suite": "Criar novo Bundle Suite", + "create-new-folder": "Criar nova pasta", "create-new-test-suite": "Criar novo conjunto de teste", "create-new-workflow": "Criar novo fluxo de trabalho", "create-widget": "Criar widget", @@ -931,6 +935,8 @@ "focus-home": "Ir para Início", "focus-on-node": "Focar no Nó", "focus-selected": "Focar na Seleção", + "folder": "Pasta", + "folder-plural": "Pastas", "follow": "Seguir", "followed-lowercase": "seguido", "follower-plural": "Seguidores", @@ -1371,6 +1377,7 @@ "move": "Mover", "move-anyway": "Mover mesmo assim", "move-the-entity": "Mover a {{entity}}", + "move-to-folder": "Mover para a pasta", "ms": "Milissegundos", "ms-team-plural": "MS Teams", "multi-select": "Seleção múltipla", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "Nenhum(a) {{entity}} atribuído(a)", "no-entity-available": "Nenhum(a) {{entity}} disponível", "no-entity-selected": "Nenhum(a) {{entity}} Selecionado(a)", + "no-folder": "Nenhuma pasta", "no-knowledge-articles-available": "Não há artigos de conhecimento disponíveis", "no-kpis-yet": "Comece a acompanhar o que importa", "no-matching-data-asset": "Nenhum ativo de dados correspondente encontrado", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 7592f5dfa3cd..06db3b770a61 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -159,7 +159,10 @@ "approver-plural": "Утверждающие", "april": "Апрель", "archive": "Архив", + "archive-file-plural": "Архивные файлы", + "archive-plural": "Архивы", "archived": "В архиве", + "archived-by": "В архиве: {{name}}", "argument-plural": "Аргументы", "arrow-symbol": "→", "article": "Статья", @@ -443,6 +446,7 @@ "create-entity": "Создать объект «{{entity}}»", "create-lowercase": "создать", "create-new-bundle-suite": "Создать новый Bundle Suite", + "create-new-folder": "Создать новую папку", "create-new-test-suite": "Создать новый набор тестов", "create-new-workflow": "Создать новый рабочий процесс", "create-widget": "Создать виджет", @@ -931,6 +935,8 @@ "focus-home": "На главную", "focus-on-node": "Фокус на узел", "focus-selected": "Фокус на выбранном", + "folder": "Папка", + "folder-plural": "Папки", "follow": "Подписаться", "followed-lowercase": "Подписан", "follower-plural": "Подписчики", @@ -1371,6 +1377,7 @@ "move": "Переместить", "move-anyway": "Переместить всё равно", "move-the-entity": "Переместите объект «{{entity}}»", + "move-to-folder": "Переместить в папку", "ms": "Миллисекунды", "ms-team-plural": "MS Команды", "multi-select": "Множественный выбор", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "{{entity}} не назначены", "no-entity-available": "Нет доступных объектов «{{entity}}»", "no-entity-selected": "Объект «{{entity}}» не выбран", + "no-folder": "Нет папки", "no-knowledge-articles-available": "Нет доступных статей базы знаний", "no-kpis-yet": "Начните отслеживать то, что важно", "no-matching-data-asset": "Подходящие объекты данных не найдены", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index cb435aaca239..13e82dae83e7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -159,7 +159,10 @@ "approver-plural": "ผู้อนุมัติ", "april": "เมษายน", "archive": "เก็บถาวร", + "archive-file-plural": "ไฟล์เก็บถาวร", + "archive-plural": "คลังเก็บถาวร", "archived": "เก็บถาวร", + "archived-by": "เก็บถาวรโดย {{name}}", "argument-plural": "อาร์กิวเมนต์", "arrow-symbol": "→", "article": "บทความ", @@ -443,6 +446,7 @@ "create-entity": "สร้าง {{entity}}", "create-lowercase": "สร้าง", "create-new-bundle-suite": "สร้าง Bundle Suite ใหม่", + "create-new-folder": "สร้างโฟลเดอร์ใหม่", "create-new-test-suite": "สร้างชุดทดสอบใหม่", "create-new-workflow": "สร้างเวิร์กโฟลว์ใหม่", "create-widget": "สร้างวิดเจ็ต", @@ -931,6 +935,8 @@ "focus-home": "โฟกัสหน้าแรก", "focus-on-node": "โฟกัสที่โหนด", "focus-selected": "โฟกัสที่เลือก", + "folder": "โฟลเดอร์", + "folder-plural": "โฟลเดอร์", "follow": "ติดตาม", "followed-lowercase": "ติดตามแล้ว", "follower-plural": "ผู้ติดตาม", @@ -1371,6 +1377,7 @@ "move": "ย้าย", "move-anyway": "ย้ายต่อไป", "move-the-entity": "ย้าย {{entity}}", + "move-to-folder": "ย้ายไปยังโฟลเดอร์", "ms": "มิลลิวินาที", "ms-team-plural": "MS Teams", "multi-select": "หลายการเลือก", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "ไม่มี {{entity}} ที่กำหนด", "no-entity-available": "ไม่มี {{entity}} ที่พร้อมใช้งาน", "no-entity-selected": "ไม่มี {{entity}} ที่เลือก", + "no-folder": "ไม่มีโฟลเดอร์", "no-knowledge-articles-available": "ไม่มีบทความความรู้ที่พร้อมใช้งาน", "no-kpis-yet": "เริ่มติดตามสิ่งที่สำคัญ", "no-matching-data-asset": "ไม่พบสินทรัพย์ข้อมูลที่ตรงกัน", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 01d20111fd5d..f6049e8ec94a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -159,7 +159,10 @@ "approver-plural": "Onaylayıcılar", "april": "Nisan", "archive": "Arşiv", + "archive-file-plural": "Arşiv Dosyaları", + "archive-plural": "Arşivler", "archived": "Arşivlendi", + "archived-by": "{{name}} tarafından arşivlendi", "argument-plural": "Argümanlar", "arrow-symbol": "→", "article": "Makale", @@ -443,6 +446,7 @@ "create-entity": "{{entity}} Oluştur", "create-lowercase": "oluştur", "create-new-bundle-suite": "Yeni Bundle Suite oluştur", + "create-new-folder": "Yeni Klasör Oluştur", "create-new-test-suite": "Yeni test paketi oluştur", "create-new-workflow": "Yeni İş Akışı Oluştur", "create-widget": "Widget oluştur", @@ -931,6 +935,8 @@ "focus-home": "Ana Sayfaya Odaklan", "focus-on-node": "Düğüme Odaklan", "focus-selected": "Seçilene Odaklan", + "folder": "Klasör", + "folder-plural": "Klasörler", "follow": "Takip Et", "followed-lowercase": "takip edildi", "follower-plural": "Takipçiler", @@ -1371,6 +1377,7 @@ "move": "Taşı", "move-anyway": "Yine de taşı", "move-the-entity": "{{entity}} Taşı", + "move-to-folder": "Klasöre taşı", "ms": "Milisaniye", "ms-team-plural": "MS Teams", "multi-select": "Çoklu Seçim", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "{{entity}} atanmadı", "no-entity-available": "Kullanılabilir {{entity}} yok", "no-entity-selected": "Seçili {{entity}} Yok", + "no-folder": "Klasör yok", "no-knowledge-articles-available": "Kullanılabilir bilgi makalesi yok", "no-kpis-yet": "Önemli olanı takip etmeye başlayın", "no-matching-data-asset": "Eşleşen veri varlığı bulunamadı", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 549689e5535e..86133b7b7a4f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -159,7 +159,10 @@ "approver-plural": "审批人", "april": "四月", "archive": "归档", + "archive-file-plural": "归档文件", + "archive-plural": "归档", "archived": "已归档", + "archived-by": "由 {{name}} 归档", "argument-plural": "参数", "arrow-symbol": "→", "article": "文章", @@ -443,6 +446,7 @@ "create-entity": "新建{{entity}}", "create-lowercase": "创建", "create-new-bundle-suite": "创建新的Bundle Suite", + "create-new-folder": "创建新文件夹", "create-new-test-suite": "创建新的质控测试", "create-new-workflow": "创建新工作流", "create-widget": "创建小组件", @@ -931,6 +935,8 @@ "focus-home": "焦点回到主页", "focus-on-node": "聚焦节点", "focus-selected": "聚焦选中项", + "folder": "文件夹", + "folder-plural": "文件夹", "follow": "关注", "followed-lowercase": "已关注", "follower-plural": "关注者", @@ -1371,6 +1377,7 @@ "move": "移动", "move-anyway": "仍然移动", "move-the-entity": "移动{{entity}}", + "move-to-folder": "移动到文件夹", "ms": "毫秒", "ms-team-plural": "微软团队", "multi-select": "多选", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "未分配 {{entity}}", "no-entity-available": "没有可用的 {{entity}}", "no-entity-selected": "未选择 {{entity}}", + "no-folder": "无文件夹", "no-knowledge-articles-available": "暂无可用的知识文章", "no-kpis-yet": "开始跟踪重要内容", "no-matching-data-asset": "未找到匹配的数据资产", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index 36873dc062a0..31ae42efebec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -159,7 +159,10 @@ "approver-plural": "审批人", "april": "四月", "archive": "歸檔", + "archive-file-plural": "歸檔文件", + "archive-plural": "歸檔", "archived": "已封存", + "archived-by": "由 {{name}} 歸檔", "argument-plural": "引數", "arrow-symbol": "→", "article": "文章", @@ -443,6 +446,7 @@ "create-entity": "建立 {{entity}}", "create-lowercase": "建立", "create-new-bundle-suite": "建立新的 Bundle Suite", + "create-new-folder": "建立新資料夾", "create-new-test-suite": "建立新測試套件", "create-new-workflow": "建立新工作流程", "create-widget": "建立小工具", @@ -931,6 +935,8 @@ "focus-home": "焦點回到首頁", "focus-on-node": "聚焦節點", "focus-selected": "聚焦已選項目", + "folder": "資料夾", + "folder-plural": "資料夾", "follow": "追蹤", "followed-lowercase": "已追蹤", "follower-plural": "追蹤者", @@ -1371,6 +1377,7 @@ "move": "移動", "move-anyway": "仍要移動", "move-the-entity": "移動 {{entity}}", + "move-to-folder": "移動到資料夾", "ms": "毫秒", "ms-team-plural": "MS Teams", "multi-select": "多選", @@ -1420,6 +1427,7 @@ "no-entity-assigned": "未指派 {{entity}}", "no-entity-available": "無可用的 {{entity}}", "no-entity-selected": "未選取 {{entity}}", + "no-folder": "無資料夾", "no-knowledge-articles-available": "目前沒有可用的知識文章", "no-kpis-yet": "開始追蹤重要指標", "no-matching-data-asset": "找不到相符的資料資產", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx index 6511d95ae071..49ab3140da0b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx @@ -11,26 +11,54 @@ * limitations under the License. */ -import { Home02 } from '@untitledui/icons'; +import { + Badge, + Card, + Tabs, + Typography, +} from '@openmetadata/ui-core-components'; +import { File06, Home02 } from '@untitledui/icons'; import { AxiosError } from 'axios'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { ReactComponent as FolderIcon } from '../../../assets/svg/ic-folder-new.svg'; +import DeleteModal from '../../../components/common/DeleteModal/DeleteModal'; +import ArchiveView from '../../../components/ContextCenter/ArchiveView/ArchiveView.component'; +import { ArchiveItem } from '../../../components/ContextCenter/ArchiveView/ArchiveView.interface'; import ContextCenterHeader from '../../../components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component'; -import { ROUTES } from '../../../constants/constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { OperationPermission, ResourceEntity, } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { Include } from '../../../generated/type/include'; +import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { PageType } from '../../../interface/knowledge-center.interface'; +import { + deleteDriveFile, + listArchivedContextFiles, + restoreDriveFile, +} from '../../../rest/assetAPI'; +import { + deleteKnowledgePage, + getListKnowledgePages, + restoreKnowledgePage, +} from '../../../rest/knowledgeCenterAPI'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; -import { showErrorToast } from '../../../utils/ToastUtils'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; + +type FilterKey = 'all' | 'mine' | 'article' | 'document'; const ContextCenterArchivePage: FC = () => { const { t } = useTranslation(); - const navigate = useNavigate(); + const { currentUser } = useApplicationStore(); const { getResourcePermission } = usePermissionProvider(); + const [allItems, setAllItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeFilter, setActiveFilter] = useState('all'); + const [itemToDelete, setItemToDelete] = useState(); + const [isDeleting, setIsDeleting] = useState(false); const [permissions, setPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); @@ -46,13 +74,120 @@ const ContextCenterArchivePage: FC = () => { } }, [getResourcePermission]); + const fetchArchivedItems = useCallback(async () => { + setIsLoading(true); + try { + const [pagesResponse, files] = await Promise.all([ + getListKnowledgePages({ + include: Include.Deleted, + limit: 1000, + pageType: PageType.ARTICLE, + }), + listArchivedContextFiles(), + ]); + + const articleItems: ArchiveItem[] = (pagesResponse.data ?? []).map( + (page) => ({ + id: page.id, + name: page.displayName ?? page.name, + type: 'article' as const, + updatedBy: page.updatedBy, + updatedAt: page.updatedAt, + }) + ); + + const documentItems: ArchiveItem[] = files.map((file) => ({ + id: file.id, + name: file.displayName ?? file.name, + type: 'document' as const, + updatedBy: file.updatedBy, + updatedAt: file.updatedAt, + })); + + const merged = [...articleItems, ...documentItems].sort( + (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) + ); + + setAllItems(merged); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setIsLoading(false); + } + }, []); + useEffect(() => { fetchPermission(); - }, [fetchPermission]); + fetchArchivedItems(); + }, [fetchPermission, fetchArchivedItems]); + + const filteredItems = useMemo(() => { + switch (activeFilter) { + case 'mine': + return allItems.filter((item) => item.updatedBy === currentUser?.name); + case 'article': + return allItems.filter((item) => item.type === 'article'); + case 'document': + return allItems.filter((item) => item.type === 'document'); + default: + return allItems; + } + }, [allItems, activeFilter, currentUser?.name]); + + const handleRestore = useCallback( + async (item: ArchiveItem) => { + try { + if (item.type === 'article') { + await restoreKnowledgePage(item.id); + } else { + await restoreDriveFile(item.id); + } + setAllItems((prev) => prev.filter((i) => i.id !== item.id)); + showSuccessToast( + t('message.entity-restored-success', { entity: item.name }) + ); + } catch (err) { + showErrorToast(err as AxiosError); + } + }, + [t] + ); + + const handleDeleteClick = useCallback((item: ArchiveItem) => { + setItemToDelete(item); + }, []); + + const handleCancelDelete = useCallback(() => { + setItemToDelete(undefined); + }, []); + + const handleConfirmDelete = useCallback(async () => { + if (!itemToDelete) { + return; + } + + try { + setIsDeleting(true); + if (itemToDelete.type === 'article') { + await deleteKnowledgePage(itemToDelete.id, false, true); + } else { + await deleteDriveFile(itemToDelete.id, true); + } + setAllItems((prev) => prev.filter((i) => i.id !== itemToDelete.id)); + showSuccessToast( + t('server.entity-deleted-successfully', { entity: itemToDelete.name }) + ); + setItemToDelete(undefined); + } catch (err) { + showErrorToast(err as AxiosError); + } finally { + setIsDeleting(false); + } + }, [itemToDelete, t]); return (
{ ]} hasPermission={permissions?.Create} subtitle={t('message.context-center-archive-subtitle')} - title={t('label.archive')} - onCreateArticle={() => navigate(ROUTES.CONTEXT_CENTER_ARTICLES)} + title={t('label.archive-plural')} /> + +
+ + {t('label.archive-file-plural')} + + + {filteredItems.length} + +
+
+ setActiveFilter(key as FilterKey)}> + + + {t('label.article-plural')} +
+ ), + }, + { + id: 'document', + label: ( +
+ + {t('label.document-plural')} +
+ ), + }, + ]} + type="button-brand"> + {(tab) => ( + + isSelected + ? 'tw:rounded-md tw:border tw:border-brand-100 tw:bg-brand-50 tw:px-3 tw:py-1.5 tw:text-sm tw:font-semibold tw:text-brand-700 tw:cursor-pointer' + : 'tw:rounded-md tw:border tw:border-gray-300 tw:bg-white tw:px-3 tw:py-1.5 tw:text-sm tw:font-semibold tw:text-quaternary tw:cursor-pointer' + } + /> + )} + + +
+ + + + + {itemToDelete && ( + + )}
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArticlesPage/ContextCenterArticlesPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArticlesPage/ContextCenterArticlesPage.tsx index 2da54c0c81c5..189b6afc41fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArticlesPage/ContextCenterArticlesPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArticlesPage/ContextCenterArticlesPage.tsx @@ -18,6 +18,7 @@ import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import AlertBar from '../../../components/AlertBar/AlertBar'; import { withActivityFeed } from '../../../components/AppRouter/withActivityFeed'; import ArticleDetailHeader from '../../../components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component'; import ArticleVersionHeader from '../../../components/ContextCenter/ArticleVersionHeader/ArticleVersionHeader.component'; @@ -38,6 +39,7 @@ import { } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { EntityTabs } from '../../../enums/entity.enum'; import LimitWrapper from '../../../hoc/LimitWrapper'; +import { useAlertStore } from '../../../hooks/useAlertStore'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useFqn } from '../../../hooks/useFqn'; import { @@ -62,6 +64,7 @@ const ContextCenterArticlesPage = () => { const { fqn } = useFqn(); const { version } = useRequiredParams<{ version?: string }>(); const { currentUser } = useApplicationStore(); + const { alert } = useAlertStore(); const USERId = currentUser?.id ?? ''; const { getResourcePermission } = usePermissionProvider(); const { getResourceLimit } = useLimitStore(); @@ -79,6 +82,7 @@ const ContextCenterArticlesPage = () => { }); const [isRightPanelOpen, setIsRightPanelOpen] = useState(true); const [showAddLinkModal, setShowAddLinkModal] = useState(false); + const [articleSearchQuery, setArticleSearchQuery] = useState(''); const handleFetchKnowledgePageHierarchy = useCallback( (forceRefresh?: boolean) => @@ -173,7 +177,6 @@ const ContextCenterArticlesPage = () => { onSave={page.handlers?.onSave} onSetThreadLink={page.handlers?.onSetThreadLink ?? (() => undefined)} onTabChange={page.onTabChange} - onToggleDelete={page.handlers?.onToggleDelete ?? (() => undefined)} onToggleRightPanel={handleToggleRightPanel} onVoteChange={page.handlers?.onVoteChange ?? (async () => undefined)} /> @@ -226,8 +229,13 @@ const ContextCenterArticlesPage = () => { { activeTitle: true, name: t('label.article-plural'), url: '' }, ]} hasPermission={permissions?.Create} + searchPlaceholder={t('label.search-entity', { + entity: t('label.article-plural'), + })} + searchQuery={articleSearchQuery} subtitle={t('message.internal-knowledge-base-agent-training')} title={t('label.article-plural')} + onSearch={setArticleSearchQuery} /> ); }; @@ -295,6 +303,7 @@ const ContextCenterArticlesPage = () => { rightPanelSlot={ contextCenterClassBase.isEmbeddedMode() ? null : undefined } + searchQuery={articleSearchQuery} onPageChange={handlePageChange} /> ); @@ -303,6 +312,7 @@ const ContextCenterArticlesPage = () => { fqn, isRightPanelOpen, permissions, + articleSearchQuery, handlePageChange, handleFetchKnowledgePageHierarchy, handleToggleRightPanel, @@ -312,6 +322,7 @@ const ContextCenterArticlesPage = () => {
+ {alert && } {renderHeader()} { const { t } = useTranslation(); const navigate = useNavigate(); + const { alert } = useAlertStore(); const { currentUser } = useApplicationStore(); const { getResourcePermission } = usePermissionProvider(); @@ -75,6 +81,9 @@ const ContextCenterDashboardPage: FC = () => { const response = await getListKnowledgePages({ fields: 'tags,page', limit: RECENT_ARTICLES_LIMIT, + pageType: PageType.ARTICLE, + sortBy: 'updatedAt', + sortOrder: 'desc', }); setArticles( response.data.map((page: KnowledgePage) => @@ -91,8 +100,8 @@ const ContextCenterDashboardPage: FC = () => { const fetchDocuments = useCallback(async () => { setIsDocumentsLoading(true); try { - const assets = await fetchContextCenterDocuments(); - setDocuments(assets.map(assetToDocumentItem)); + const files = await listContextFiles(RECENT_DOCUMENTS_LIMIT); + setDocuments(files.map(contextFileToUploadedDocumentItem)); } catch (err) { showErrorToast(err as AxiosError); } finally { @@ -117,14 +126,18 @@ const ContextCenterDashboardPage: FC = () => { fetchPermission(); }, [fetchRecentArticles, fetchDocuments, fetchPermission]); - const handleUploaded = useCallback((newAssets: Asset[]) => { - setDocuments((prev) => [...newAssets.map(assetToDocumentItem), ...prev]); + const handleUploaded = useCallback((newFiles: ContextFile[]) => { + setDocuments((prev) => [ + ...newFiles.map(contextFileToUploadedDocumentItem), + ...prev, + ]); }, []); return (
+ {alert && } {
setIsUploadModalOpen(false)} onUploaded={handleUploaded} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterDocumentsPage/ContextCenterDocumentsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterDocumentsPage/ContextCenterDocumentsPage.tsx index 033619e5ad91..89c1575c9b45 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterDocumentsPage/ContextCenterDocumentsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterDocumentsPage/ContextCenterDocumentsPage.tsx @@ -15,22 +15,32 @@ import { Home02 } from '@untitledui/icons'; import { AxiosError } from 'axios'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex'; +import AlertBar from '../../../components/AlertBar/AlertBar'; import DeleteModal from '../../../components/common/DeleteModal/DeleteModal'; +import '../../../components/common/ResizablePanels/resizable-panels.less'; import ContextCenterHeader from '../../../components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component'; +import DocumentFolderView from '../../../components/ContextCenter/DocumentsView/DocumentFolderView.component'; import DocumentsView from '../../../components/ContextCenter/DocumentsView/DocumentsView.component'; -import { DocFile } from '../../../components/ContextCenter/DocumentsView/DocumentsView.interface'; +import { + DocFile, + FolderOption, +} from '../../../components/ContextCenter/DocumentsView/DocumentsView.interface'; import UploadDocumentModal from '../../../components/ContextCenter/UploadDocumentModal/UploadDocumentModal.component'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { OperationPermission, ResourceEntity, } from '../../../context/PermissionProvider/PermissionProvider.interface'; -import { deleteAsset } from '../../../rest/assetAPI'; +import { SearchIndex } from '../../../enums/search.enum'; +import { ContextFile } from '../../../generated/entity/data/contextFile'; +import { Folder } from '../../../generated/entity/data/folder'; +import { useAlertStore } from '../../../hooks/useAlertStore'; +import { deleteDriveFile, listContextFiles } from '../../../rest/assetAPI'; +import { searchQuery as fetchSearchResults } from '../../../rest/searchAPI'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import { - assetToDocumentItem, - CONTEXT_CENTER_DOCUMENTS_ENTITY_LINK, - fetchContextCenterDocuments, + contextFileToDocumentItem, handleAssetDownload, } from '../../../utils/ContextCenterUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; @@ -38,15 +48,19 @@ import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; const ContextCenterDocumentsPage: FC = () => { const { t } = useTranslation(); + const { alert } = useAlertStore(); const { getResourcePermission } = usePermissionProvider(); - const [documents, setDocuments] = useState([]); + const [allDocuments, setAllDocuments] = useState([]); const [isDocumentsLoading, setIsDocumentsLoading] = useState(true); + const [documentSearchQuery, setDocumentSearchQuery] = useState(''); const [isDeletingFile, setIsDeletingFile] = useState(false); const [fileToDelete, setFileToDelete] = useState(); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [permissions, setPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); + const [selectedFolderId, setSelectedFolderId] = useState(); + const [folders, setFolders] = useState([]); const { hasCreatePermission, hasDeletePermission } = useMemo( () => ({ @@ -56,21 +70,60 @@ const ContextCenterDocumentsPage: FC = () => { [permissions.Create, permissions.Delete] ); + const selectedFolderFqn = useMemo( + () => + selectedFolderId + ? folders.find((f) => f.id === selectedFolderId)?.fullyQualifiedName + : undefined, + [selectedFolderId, folders] + ); + + const folderOptions = useMemo( + () => + folders.map((f) => ({ + id: f.id, + name: f.displayName ?? f.name, + })), + [folders] + ); + + const documents = useMemo(() => { + if (!selectedFolderId) { + return allDocuments; + } + + return allDocuments.filter((d) => d.folderId === selectedFolderId); + }, [allDocuments, selectedFolderId]); + const fetchDocuments = useCallback(async () => { setIsDocumentsLoading(true); try { - const assets = await fetchContextCenterDocuments(); - setDocuments(assets.map(assetToDocumentItem)); + if (documentSearchQuery) { + const results = await fetchSearchResults({ + query: documentSearchQuery, + searchIndex: SearchIndex.DRIVE_FILE, + sortField: 'updatedAt', + sortOrder: 'desc', + }); + setAllDocuments( + results.hits.hits.map((hit) => + contextFileToDocumentItem(hit._source as unknown as ContextFile) + ) + ); + } else { + const files = await listContextFiles(); + setAllDocuments(files.map(contextFileToDocumentItem)); + } } catch (err) { showErrorToast(err as AxiosError); } finally { setIsDocumentsLoading(false); } - }, []); + }, [documentSearchQuery]); useEffect(() => { fetchDocuments(); - }, []); + }, [fetchDocuments]); const fetchPermission = useCallback(async () => { try { @@ -102,8 +155,8 @@ const ContextCenterDocumentsPage: FC = () => { try { setIsDeletingFile(true); - await deleteAsset(fileToDelete.id, true); - setDocuments((prev) => + await deleteDriveFile(fileToDelete.driveFileId ?? fileToDelete.id, false); + setAllDocuments((prev) => prev.filter((document) => document.id !== fileToDelete.id) ); showSuccessToast( @@ -119,10 +172,22 @@ const ContextCenterDocumentsPage: FC = () => { } }, [fileToDelete, t]); + const handleFileMoved = useCallback( + (file: DocFile, targetFolderId: string) => { + setAllDocuments((prev) => + prev.map((d) => + d.id === file.id ? { ...d, folderId: targetFolderId } : d + ) + ); + }, + [] + ); + return (
+ {alert && } { }, ]} hasPermission={hasCreatePermission} + searchPlaceholder={t('label.search-entity', { + entity: t('label.document-plural'), + })} + searchQuery={documentSearchQuery} subtitle={t('message.context-center-documents-subtitle')} title={t('label.document-plural')} + onSearch={setDocumentSearchQuery} onUploadFile={() => setIsUploadModalOpen(true)} /> -
- -
+ + + + + + +
+
+
+ + + + + + setIsUploadModalOpen(false)} - onUploaded={() => fetchDocuments()} + onUploaded={(newFiles) => + setAllDocuments((prev) => [ + ...newFiles.map(contextFileToDocumentItem), + ...prev, + ]) + } /> {fileToDelete && ( { ); const headerActions = ( -
- - -
+ ); return ( @@ -393,8 +376,11 @@ const ContextCenterMemoriesPage: FC = () => { url: '', }, ]} + searchPlaceholder={t('label.search-memories')} + searchQuery={searchValue} subtitle={t('message.context-center-memories-subtitle')} title={t('label.memory-plural')} + onSearch={handleSearchChange} /> {/* Stats cards */} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts index 93e6af682484..26c1f610a8fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts @@ -12,8 +12,80 @@ */ import { AxiosResponse } from 'axios'; import { Asset, AssetType } from '../generated/attachments/asset'; +import { ContextFile } from '../generated/entity/data/contextFile'; +import { Folder } from '../generated/entity/data/folder'; import APIClient from './index'; +export interface CreateFolderRequest { + name: string; + displayName?: string; +} + +export const createFolder = async ( + data: CreateFolderRequest +): Promise => { + const response = await APIClient.post( + '/contextCenter/drive/folders', + data + ); + + return response.data; +}; + +export const listFolders = async (): Promise => { + const response = await APIClient.get<{ data: Folder[] }>( + '/contextCenter/drive/folders' + ); + + return response.data.data ?? []; +}; + +export const deleteFolder = async ( + id: string, + hardDelete = false +): Promise => { + await APIClient.delete(`/contextCenter/drive/folders/${id}`, { + params: { hardDelete }, + }); +}; + +export const listContextFiles = async (limit = 100): Promise => { + const response = await APIClient.get<{ data: ContextFile[] }>( + '/contextCenter/drive/files', + { params: { fields: 'folder', limit } } + ); + + return response.data.data ?? []; +}; + +export const moveFileToFolder = async ( + driveFileId: string, + folderId: string +): Promise => { + await APIClient.put(`/contextCenter/drive/files/${driveFileId}/move`, { + folder: { id: folderId, type: 'folder' }, + }); +}; + +export const uploadDriveFile = async ( + file: File, + folderFqn?: string +): Promise => { + const formData = new FormData(); + formData.append('file', file); + + if (folderFqn) { + formData.append('folder', folderFqn); + } + + const response = await APIClient.post>( + '/contextCenter/drive/files/upload', + formData + ); + + return response.data; +}; + export const uploadAsset = async ( file: File, entityLink: string, @@ -32,12 +104,56 @@ export const uploadAsset = async ( return response.data; }; +export interface ListAssetsByFqnParams { + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + limit?: number; +} + export const listAssetsByFqn = async ( fqn: string, - assetType: AssetType = AssetType.External + assetType: AssetType = AssetType.External, + params?: ListAssetsByFqnParams ): Promise => { const response = await APIClient.get( - `/attachments/fqn/${encodeURIComponent(fqn)}/${assetType}` + `/attachments/fqn/${encodeURIComponent(fqn)}/${assetType}`, + { params } + ); + + return response.data; +}; + +export const deleteDriveFile = async ( + id: string, + hardDelete = false +): Promise => { + await APIClient.delete(`/contextCenter/drive/files/${id}`, { + params: { hardDelete }, + }); +}; + +export const listArchivedContextFiles = async (): Promise => { + const response = await APIClient.get<{ data: ContextFile[] }>( + '/contextCenter/drive/files', + { params: { include: 'deleted', limit: 1000 } } + ); + + return response.data.data ?? []; +}; + +export const restoreDriveFile = async (id: string): Promise => { + const response = await APIClient.put< + { id: string }, + AxiosResponse + >('/contextCenter/drive/files/restore', { id }); + + return response.data; +}; + +export const downloadDriveFile = async (id: string): Promise => { + const response = await APIClient.get( + `/contextCenter/drive/files/${id}/download`, + { params: { redirect: true, expiry: 300 }, responseType: 'blob' } ); return response.data; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/knowledgeCenterAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/knowledgeCenterAPI.ts index 50982b3d8257..738d9fa5d8b5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/knowledgeCenterAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/knowledgeCenterAPI.ts @@ -12,6 +12,7 @@ */ import { AxiosResponse } from 'axios'; import { Operation } from 'fast-json-patch'; +import { PagingResponse } from 'Models'; import { VotingDataProps } from '../components/Entity/Voting/voting.interface'; import { EntityReference } from '../generated/entity/type'; import { EntityHistory } from '../generated/type/entityHistory'; @@ -24,7 +25,6 @@ import { PageHierarchy, PageType, } from '../interface/knowledge-center.interface'; -import { PagingResponse } from '../Models'; import APIClient from '../rest/index'; export interface KnowledgePageHierarchyParams { @@ -39,6 +39,9 @@ export type KnowledgePageListParams = ListParams & { entityType?: string; entityId?: string; tagFQN?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + offset?: number; }; export const getListKnowledgePages = async ( @@ -66,6 +69,27 @@ export const getKnowledgePageByFqn = async ( return response.data; }; +export const deleteKnowledgePage = async ( + id: string, + recursive = true, + hardDelete = false +): Promise => { + await APIClient.delete(`/contextCenter/pages/${id}`, { + params: { recursive, hardDelete }, + }); +}; + +export const restoreKnowledgePage = async ( + id: string +): Promise => { + const response = await APIClient.put< + { id: string }, + AxiosResponse + >('/contextCenter/pages/restore', { id }); + + return response.data; +}; + export const postKnowledgePage = async (data: CreateKnowledgePage) => { const response = await APIClient.post< CreateKnowledgePage, @@ -150,14 +174,6 @@ export const unFollowKnowledgePage = async ( return response.data; }; -export const deleteKnowledgePage = async (id: string, recursive = true) => { - const response = await APIClient.delete( - `/contextCenter/pages/${id}?hardDelete=true&recursive=${recursive}` - ); - - return response.data; -}; - export const getKnowledgePageVersionsList = async (id: string) => { const url = `contextCenter/pages/${id}/versions`; const response = await APIClient.get(url); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.test.ts index f85037d9d7af..7f9add5a4302 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.test.ts @@ -15,7 +15,7 @@ import { AxiosError } from 'axios'; import { ROUTES } from '../constants/constants'; import { Asset } from '../generated/attachments/asset'; import { PageType } from '../interface/knowledge-center.interface'; -import { downloadAsset } from '../rest/assetAPI'; +import { downloadDriveFile } from '../rest/assetAPI'; import { assetToDocumentItem, extensionToFileType, @@ -31,7 +31,7 @@ jest.mock('./ToastUtils', () => ({ })); jest.mock('../rest/assetAPI', () => ({ - downloadAsset: jest.fn(), + downloadDriveFile: jest.fn(), })); jest.mock('./KnowledgePageUtils', () => ({ @@ -183,7 +183,7 @@ describe('handleAssetDownload', () => { }); it('should download asset successfully', async () => { - (downloadAsset as jest.Mock).mockResolvedValue(mockBlob); + (downloadDriveFile as jest.Mock).mockResolvedValue(mockBlob); const clickMock = jest.fn(); const removeMock = jest.fn(); @@ -199,7 +199,7 @@ describe('handleAssetDownload', () => { await handleAssetDownload(mockFile as any); - expect(downloadAsset).toHaveBeenCalledWith('123'); + expect(downloadDriveFile).toHaveBeenCalledWith('123'); expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); @@ -215,7 +215,7 @@ describe('handleAssetDownload', () => { it('should show error toast when download fails', async () => { const error = new Error('Download failed'); - (downloadAsset as jest.Mock).mockRejectedValue(error); + (downloadDriveFile as jest.Mock).mockRejectedValue(error); await handleAssetDownload(mockFile as any); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.tsx index 31eb08e54636..6df29a4adbd0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterUtils.tsx @@ -15,22 +15,32 @@ import { File06 } from '@untitledui/icons'; import { AxiosError } from 'axios'; import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill'; import { isNull, isUndefined } from 'lodash'; +import { FC } from 'react'; import { ReactComponent as DOCIcon } from '../assets/svg/ic-doc.svg'; import { ReactComponent as ImageIcon } from '../assets/svg/ic-image.svg'; import { ReactComponent as PDFIcon } from '../assets/svg/ic-pdf.svg'; import { ReactComponent as XLSIcon } from '../assets/svg/ic-xls.svg'; import { ArticleCardItem } from '../components/ContextCenter/ArticleCard/ArticleCard.interface'; -import { DocFile } from '../components/ContextCenter/DocumentsView/DocumentsView.interface'; +import { + DocFile, + DocFileType, +} from '../components/ContextCenter/DocumentsView/DocumentsView.interface'; import { UploadedDocumentItem } from '../components/ContextCenter/UploadedDocumentCard/UploadedDocumentCard.interface'; import { CREATE_PAGE_HASH } from '../constants/constants'; +import { FILE_TYPE_STYLES } from '../constants/ContextCenter.constants'; import { EntityType } from '../enums/entity.enum'; import { Asset, AssetType } from '../generated/attachments/asset'; +import { ContextFile } from '../generated/entity/data/contextFile'; import { CreateKnowledgePage, PageType, QuickLink, } from '../interface/knowledge-center.interface'; -import { downloadAsset, listAssetsByFqn } from '../rest/assetAPI'; +import { + downloadDriveFile, + listAssetsByFqn, + ListAssetsByFqnParams, +} from '../rest/assetAPI'; import { postKnowledgePage } from '../rest/knowledgeCenterAPI'; import contextCenterClassBase from './ContextCenterClassBase'; import EntityLink from './EntityLink'; @@ -114,6 +124,31 @@ export const assetToDocumentItem = (asset: Asset): UploadedDocumentItem => ({ updatedAt: asset.updatedAt ?? 0, }); +export const contextFileToDocumentItem = (file: ContextFile): DocFile => ({ + driveFileId: file.id, + fileType: extensionToFileType(file.displayName ?? file.name), + folderId: file.folder?.id, + folderFqn: file.folder?.fullyQualifiedName, + id: file.assetId ?? file.id, + name: file.displayName ?? file.name, + sizeLabel: formatBytes(file.fileSize), + updatedAt: file.updatedAt, + updatedBy: file.updatedBy, +}); + +export const contextFileToUploadedDocumentItem = ( + file: ContextFile +): UploadedDocumentItem => ({ + driveFileId: file.id, + fileType: extensionToFileType(file.displayName ?? file.name), + id: file.assetId ?? file.id, + name: file.displayName ?? file.name, + sizeLabel: formatBytes(file.fileSize), + status: 'processed', + updatedAt: file.updatedAt ?? 0, + updatedBy: file.updatedBy ?? '', +}); + export const knowledgePageToArticleItem = ( data: { id: string; @@ -142,8 +177,14 @@ export const knowledgePageToArticleItem = ( title: getEntityName(data) || untitledLabel, }); -export const fetchContextCenterDocuments = async (): Promise => { - return listAssetsByFqn(CONTEXT_CENTER_DOCUMENTS_FQN, AssetType.External); +export const fetchContextCenterDocuments = async ( + params?: ListAssetsByFqnParams +): Promise => { + return listAssetsByFqn( + CONTEXT_CENTER_DOCUMENTS_FQN, + AssetType.External, + params + ); }; export const createArticleKnowledgePage = async ( @@ -181,7 +222,7 @@ export const handleAssetDownload = async (file: DocFile) => { let element: HTMLAnchorElement | undefined; try { - const blob = await downloadAsset(file.id); + const blob = await downloadDriveFile(file.driveFileId ?? file.id); url = URL.createObjectURL(blob); element = document.createElement('a'); element.href = url; @@ -198,3 +239,14 @@ export const handleAssetDownload = async (file: DocFile) => { } } }; + +export const FileTypeLabel: FC<{ fileType: DocFileType }> = ({ fileType }) => { + const { bg, label, text } = FILE_TYPE_STYLES[fileType || 'other']; + + return ( + + {label} + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts index 6676215b75c3..c04ea4ba745b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts @@ -47,8 +47,13 @@ type TagWidgetKeys = | DetailPageWidgetKeys.DOMAIN; class TagClassBase { + static filterClassification: string[] = []; defaultWidgetHeight: Record; + public setFilterClassification(value: string[]) { + TagClassBase.filterClassification = value; + } + constructor() { this.defaultWidgetHeight = { [DetailPageWidgetKeys.DESCRIPTION]: 4,