mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 03:17:15 +08:00
update
This commit is contained in:
@ -12,12 +12,18 @@ import {
|
||||
} from './common'
|
||||
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
} from './list-styles'
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCard,
|
||||
DetailTableCardList,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from './table'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from './table-styles'
|
||||
|
||||
function NewDeploymentButton({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
@ -44,9 +50,9 @@ function DeploymentEnvironmentListSkeleton() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<DetailTableCard key={key}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
@ -61,39 +67,43 @@ function DeploymentEnvironmentListSkeleton() {
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
))}
|
||||
</div>
|
||||
</DetailTableCardList>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('deployTab.col.environment')}</div>
|
||||
<div>{t('deployTab.col.status')}</div>
|
||||
<div>{t('deployTab.col.currentRelease')}</div>
|
||||
<div className="text-right">{t('deployTab.col.actions')}</div>
|
||||
</div>
|
||||
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<DetailTable>
|
||||
<DetailTableHeader>
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
|
||||
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<DetailTableRow key={key}>
|
||||
<DetailTableCell>
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<SkeletonRow className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
|
||||
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
))}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
@ -30,13 +30,19 @@ import { releaseCommit, releaseLabel } from '../../release'
|
||||
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME,
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
} from '../list-styles'
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCard,
|
||||
DetailTableCardList,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../table'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME,
|
||||
} from '../table-styles'
|
||||
import { DeploymentStatusSummary } from './deployment-status-summary'
|
||||
|
||||
function EnvironmentSummary({ environment }: {
|
||||
@ -131,7 +137,7 @@ function DeploymentRowActions({ appInstanceId, envId, row }: {
|
||||
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
|
||||
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
@ -236,7 +242,7 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
|
||||
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
|
||||
|
||||
return (
|
||||
<div className="border-b border-divider-subtle last:border-b-0">
|
||||
<DetailTableCard>
|
||||
<div className="flex flex-col gap-3 p-4 text-left">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<EnvironmentSummary environment={row.environment} />
|
||||
@ -253,7 +259,7 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
|
||||
<span className="min-w-0 flex-1 truncate">{row.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
)
|
||||
}
|
||||
|
||||
@ -270,31 +276,34 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
|
||||
const isLast = index === rows.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={envId}
|
||||
className={DETAIL_LIST_ROW_CLASS_NAME}
|
||||
>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<Fragment key={envId}>
|
||||
<DetailTableRow>
|
||||
<DetailTableCell>
|
||||
<EnvironmentSummary environment={row.environment} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<DeploymentStatusSummary row={row} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<CurrentReleaseSummary release={row.currentRelease} />
|
||||
</div>
|
||||
<div className="flex w-8 justify-end">
|
||||
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex justify-end">
|
||||
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
{showFailureBanner && (
|
||||
<div className={cn('flex items-center gap-2 border-t border-l-2 border-divider-subtle border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
|
||||
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{row.status}</span>
|
||||
</div>
|
||||
<DetailTableRow className="hover:bg-transparent">
|
||||
<DetailTableCell colSpan={4} className="h-auto p-0">
|
||||
<div className={cn('flex items-center gap-2 border-l-2 border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
|
||||
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{row.status}</span>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
@ -309,7 +318,7 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(DETAIL_LIST_CLASS_NAME, 'pc:hidden')}>
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{rows.map(row => (
|
||||
<DeploymentEnvironmentMobileRow
|
||||
key={environmentId(row.environment)}
|
||||
@ -317,17 +326,21 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailTableCardList>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('deployTab.col.environment')}</div>
|
||||
<div>{t('deployTab.col.status')}</div>
|
||||
<div>{t('deployTab.col.currentRelease')}</div>
|
||||
<div className="text-right">{t('deployTab.col.actions')}</div>
|
||||
</div>
|
||||
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
|
||||
</div>
|
||||
<DetailTable>
|
||||
<DetailTableHeader>
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
|
||||
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
|
||||
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
export const DETAIL_LIST_CLASS_NAME = 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default'
|
||||
export const DETAIL_LIST_ROW_CLASS_NAME = 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover'
|
||||
export const DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
|
||||
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)
|
||||
export const DETAIL_LIST_HEADER_ROW_CLASS_NAME = 'grid min-h-8 items-center gap-6 border-b border-divider-subtle px-4 py-1.5 system-2xs-medium-uppercase text-text-tertiary'
|
||||
export const DETAIL_LIST_DESKTOP_ROW_CLASS_NAME = 'grid min-h-12 items-center gap-6 px-4 py-2'
|
||||
export const DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(160px,1fr)_minmax(150px,0.75fr)_minmax(180px,1fr)_auto]'
|
||||
export const RELEASE_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(150px,1fr)_minmax(130px,0.75fr)_minmax(140px,0.8fr)_minmax(150px,1fr)_auto]'
|
||||
@ -0,0 +1,106 @@
|
||||
import type { DeveloperApiKeyRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiKeyList } from '../api-keys'
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
appDeployAccessService: {
|
||||
deleteDeveloperApiKey: {
|
||||
mutationOptions: () => ({ mutationFn: vi.fn() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function apiKey(overrides: Partial<DeveloperApiKeyRow> = {}): DeveloperApiKeyRow {
|
||||
return {
|
||||
id: 'key-1',
|
||||
name: 'production-key-001',
|
||||
environment: { id: 'env-1', name: 'production' },
|
||||
maskedKey: 'app-****-abcd',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderApiKeyList(apiKeys: DeveloperApiKeyRow[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiKeyList appInstanceId="instance-1" apiKeys={apiKeys} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ApiKeyList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// API keys should match the shared semantic table shell used by deployment detail tabs.
|
||||
describe('Rendering', () => {
|
||||
it('should render API keys with the shared detail table design', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderApiKeyList([apiKey()])
|
||||
|
||||
// Assert
|
||||
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
|
||||
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
|
||||
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
|
||||
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
|
||||
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
|
||||
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
|
||||
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
|
||||
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
|
||||
|
||||
expect(tableContainer).toHaveClass(
|
||||
'overflow-hidden',
|
||||
'rounded-lg',
|
||||
'border',
|
||||
'border-divider-subtle',
|
||||
'bg-background-default',
|
||||
)
|
||||
expect(tableShell?.tagName).toBe('TABLE')
|
||||
expect(header?.tagName).toBe('THEAD')
|
||||
expect(body?.tagName).toBe('TBODY')
|
||||
expect(row?.tagName).toBe('TR')
|
||||
expect(head?.tagName).toBe('TH')
|
||||
expect(cell?.tagName).toBe('TD')
|
||||
expect(tableShell).toHaveClass(
|
||||
'w-full',
|
||||
'table-fixed',
|
||||
'border-collapse',
|
||||
'caption-bottom',
|
||||
)
|
||||
expect(head).toHaveClass(
|
||||
'h-9',
|
||||
'px-4',
|
||||
'py-2',
|
||||
'system-sm-medium-uppercase',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
expect(row).toHaveClass(
|
||||
'border-b',
|
||||
'border-divider-subtle',
|
||||
'hover:bg-background-default-hover',
|
||||
)
|
||||
expect(cell).toHaveClass(
|
||||
'h-12',
|
||||
'min-w-0',
|
||||
'px-4',
|
||||
'py-2',
|
||||
)
|
||||
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
|
||||
expect(screen.getAllByLabelText('deployments.access.revoke')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -13,15 +13,58 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
import {
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCard,
|
||||
DetailTableCardList,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../table'
|
||||
import {
|
||||
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from '../../table-styles'
|
||||
|
||||
function ApiKeyRow({ appInstanceId, apiKey }: {
|
||||
function ApiKeyName({ apiKey }: {
|
||||
apiKey: DeveloperApiKeyRow
|
||||
}) {
|
||||
return (
|
||||
<span className="block truncate system-sm-medium text-text-primary">
|
||||
{apiKey.name || apiKey.id || '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentBadge({ environment }: {
|
||||
environment: DeveloperApiKeyRow['environment']
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
|
||||
<span className="truncate">{environmentName(environment)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyValue({ value }: {
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RevokeApiKeyButton({ appInstanceId, apiKey }: {
|
||||
appInstanceId: string
|
||||
apiKey: DeveloperApiKeyRow
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const revokeApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.deleteDeveloperApiKey.mutationOptions())
|
||||
const displayValue = apiKey.maskedKey || apiKey.id || '—'
|
||||
const environmentLabel = environmentName(apiKey.environment)
|
||||
|
||||
function handleRevoke() {
|
||||
const environmentId = apiKey.environment?.id
|
||||
@ -38,35 +81,70 @@ function ApiKeyRow({ appInstanceId, apiKey }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-t border-divider-subtle">
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<div className="max-w-54 truncate system-sm-medium text-text-primary">
|
||||
{apiKey.name || apiKey.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
|
||||
<span className="truncate">{environmentLabel}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
|
||||
{displayValue}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevoke}
|
||||
aria-label={t('access.revoke')}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span className="i-ri-delete-bin-line size-3.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyMobileRow({ appInstanceId, apiKey }: {
|
||||
appInstanceId: string
|
||||
apiKey: DeveloperApiKeyRow
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const displayValue = apiKey.maskedKey || apiKey.id || '—'
|
||||
|
||||
return (
|
||||
<DetailTableCard>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<ApiKeyName apiKey={apiKey} />
|
||||
<div className="mt-1">
|
||||
<EnvironmentBadge environment={apiKey.environment} />
|
||||
</div>
|
||||
</div>
|
||||
<RevokeApiKeyButton appInstanceId={appInstanceId} apiKey={apiKey} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right align-middle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevoke}
|
||||
aria-label={t('access.revoke')}
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
>
|
||||
<span className="i-ri-delete-bin-line size-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('access.api.table.key')}
|
||||
</span>
|
||||
<ApiKeyValue value={displayValue} />
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyDesktopRow({ appInstanceId, apiKey }: {
|
||||
appInstanceId: string
|
||||
apiKey: DeveloperApiKeyRow
|
||||
}) {
|
||||
const displayValue = apiKey.maskedKey || apiKey.id || '—'
|
||||
|
||||
return (
|
||||
<DetailTableRow>
|
||||
<DetailTableCell>
|
||||
<ApiKeyName apiKey={apiKey} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<EnvironmentBadge environment={apiKey.environment} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<ApiKeyValue value={displayValue} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex justify-end">
|
||||
<RevokeApiKeyButton appInstanceId={appInstanceId} apiKey={apiKey} />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
}
|
||||
|
||||
@ -74,22 +152,14 @@ function ApiKeyTableHeader() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<thead>
|
||||
<tr className="bg-background-default-subtle text-left system-xs-medium-uppercase text-text-tertiary">
|
||||
<th className="w-56 px-3 py-2 font-medium">
|
||||
{t('access.api.table.name')}
|
||||
</th>
|
||||
<th className="w-40 px-3 py-2 font-medium">
|
||||
{t('access.api.table.environment')}
|
||||
</th>
|
||||
<th className="px-3 py-2 font-medium">
|
||||
{t('access.api.table.key')}
|
||||
</th>
|
||||
<th className="w-18 px-3 py-2 text-right font-medium">
|
||||
{t('access.api.table.action')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DetailTableHeader>
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
|
||||
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
|
||||
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
|
||||
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
@ -98,20 +168,31 @@ function ApiKeyTable({ appInstanceId, apiKeys }: {
|
||||
apiKeys: DeveloperApiKeyRow[]
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-divider-subtle">
|
||||
<table className="w-full min-w-175 table-fixed border-collapse">
|
||||
<ApiKeyTableHeader />
|
||||
<tbody className="bg-components-panel-bg">
|
||||
{apiKeys.map(apiKey => (
|
||||
<ApiKeyRow
|
||||
key={apiKey.id}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
<DetailTableCardList className={cn('pc:hidden')}>
|
||||
{apiKeys.map((apiKey, index) => (
|
||||
<ApiKeyMobileRow
|
||||
key={apiKey.id ?? apiKey.maskedKey ?? apiKey.name ?? index}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</DetailTableCardList>
|
||||
<div className="hidden pc:block">
|
||||
<DetailTable>
|
||||
<ApiKeyTableHeader />
|
||||
<DetailTableBody>
|
||||
{apiKeys.map((apiKey, index) => (
|
||||
<ApiKeyDesktopRow
|
||||
key={apiKey.id ?? apiKey.maskedKey ?? apiKey.name ?? index}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -5,9 +5,20 @@ import type {
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Section, SectionState } from '../../common'
|
||||
import {
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../table'
|
||||
import {
|
||||
ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from '../../table-styles'
|
||||
import { EnvironmentPermissionRow } from './permissions'
|
||||
|
||||
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
|
||||
@ -19,19 +30,33 @@ function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow
|
||||
}
|
||||
|
||||
function AccessPermissionsSkeleton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow
|
||||
key={key}
|
||||
className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-center"
|
||||
>
|
||||
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
<DetailTable>
|
||||
<DetailTableHeader className="hidden pc:table-header-group">
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
|
||||
<DetailTableRow key={key} className="block pc:table-row">
|
||||
<DetailTableCell className="block h-auto px-4 pt-3 pb-1 pc:table-cell pc:h-12 pc:py-3">
|
||||
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="block h-auto px-4 py-1 pc:table-cell pc:h-12 pc:py-3">
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="block h-auto px-4 pt-1 pb-3 pc:table-cell pc:h-12 pc:py-3">
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
))}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
)
|
||||
}
|
||||
|
||||
@ -66,16 +91,25 @@ export function AccessPermissionsSection({
|
||||
</SectionState>
|
||||
)
|
||||
: (
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
{permissionRows.map(row => (
|
||||
<EnvironmentPermissionRow
|
||||
key={row.environment.id}
|
||||
appInstanceId={appInstanceId}
|
||||
environment={row.environment}
|
||||
summaryPolicy={row}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DetailTable>
|
||||
<DetailTableHeader className="hidden pc:table-header-group">
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
|
||||
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{permissionRows.map(row => (
|
||||
<EnvironmentPermissionRow
|
||||
key={row.environment.id}
|
||||
appInstanceId={appInstanceId}
|
||||
environment={row.environment}
|
||||
summaryPolicy={row}
|
||||
/>
|
||||
))}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
|
||||
@ -39,6 +39,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
import {
|
||||
DetailTableCell,
|
||||
DetailTableRow,
|
||||
} from '../../table'
|
||||
|
||||
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
|
||||
@ -412,20 +416,20 @@ export function EnvironmentPermissionRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="min-w-0 lg:pt-1.5">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
<DetailTableRow className="block pc:table-row">
|
||||
<DetailTableCell className="block h-auto px-4 pt-3 pb-1 align-top pc:table-cell pc:h-12 pc:py-3">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary pc:hidden">
|
||||
{t('access.permissions.col.environment')}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 pc:mt-0">
|
||||
<span className="i-ri-server-line size-3.5 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate system-sm-medium text-text-primary">
|
||||
{environmentName(environment)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="block h-auto px-4 py-1 align-top pc:table-cell pc:h-12 pc:py-3">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
|
||||
{t('access.permissions.col.permission')}
|
||||
</div>
|
||||
<PermissionPicker
|
||||
@ -433,9 +437,9 @@ export function EnvironmentPermissionRow({
|
||||
disabled={controlsDisabled}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="block h-auto px-4 pt-1 pb-3 align-top pc:table-cell pc:h-12 pc:py-3">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
|
||||
{t('access.permissions.col.subjects')}
|
||||
</div>
|
||||
{permissionKind === 'specific'
|
||||
@ -458,7 +462,7 @@ export function EnvironmentPermissionRow({
|
||||
{t(`access.permission.${permissionKind}Desc`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
}
|
||||
|
||||
36
web/features/deployments/detail/table-styles.ts
Normal file
36
web/features/deployments/detail/table-styles.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
actions: 'w-14',
|
||||
currentRelease: 'w-[34%]',
|
||||
environment: 'w-[34%]',
|
||||
status: 'w-[24%]',
|
||||
}
|
||||
|
||||
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
action: 'w-14',
|
||||
author: 'w-[18%]',
|
||||
createdAt: 'w-[18%]',
|
||||
deployedTo: 'w-[28%]',
|
||||
release: 'w-[28%]',
|
||||
}
|
||||
|
||||
export const ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
environment: 'w-[22%]',
|
||||
permission: 'w-[28%]',
|
||||
subjects: 'w-[50%]',
|
||||
}
|
||||
|
||||
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
action: 'w-16',
|
||||
environment: 'w-[20%]',
|
||||
key: 'w-[38%]',
|
||||
name: 'w-[28%]',
|
||||
}
|
||||
|
||||
export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
|
||||
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)
|
||||
91
web/features/deployments/detail/table.tsx
Normal file
91
web/features/deployments/detail/table.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type DetailTableProps = ComponentProps<'table'> & {
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
export function DetailTable({ className, containerClassName, ...props }: DetailTableProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-detail-table-container"
|
||||
className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="deployment-detail-table"
|
||||
className={cn('w-full table-fixed caption-bottom border-collapse bg-background-default', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableHeader({ className, ...props }: ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="deployment-detail-table-header"
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableBody({ className, ...props }: ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="deployment-detail-table-body"
|
||||
className={cn('[&_tr:last-child]:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableRow({ className, ...props }: ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="deployment-detail-table-row"
|
||||
className={cn('border-b border-divider-subtle transition-colors hover:bg-background-default-hover', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableHead({ className, ...props }: ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="deployment-detail-table-head"
|
||||
className={cn('h-9 px-4 py-2 text-left align-middle system-sm-medium-uppercase whitespace-nowrap text-text-tertiary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableCell({ className, ...props }: ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="deployment-detail-table-cell"
|
||||
className={cn('h-12 min-w-0 px-4 py-2 align-middle', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableCardList({ className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-detail-table-card-list"
|
||||
className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailTableCard({ className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-detail-table-card"
|
||||
className={cn('border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -100,31 +100,61 @@ describe('ReleaseHistoryTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The desktop release history should use the same compact table shell as knowledge documents.
|
||||
// The desktop release history should use the shared semantic deployment detail table.
|
||||
describe('Rendering', () => {
|
||||
it('should render the desktop release history as a compact document-style table', () => {
|
||||
it('should render the desktop release history with the shared detail table design', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ReleaseHistoryTable appInstanceId="instance-1" />)
|
||||
|
||||
// Assert
|
||||
const table = screen.getByRole('table')
|
||||
expect(table).toHaveClass('border-collapse', 'border-0', 'text-sm', 'min-w-[700px]')
|
||||
expect(container.querySelector('thead')).toHaveClass(
|
||||
'h-8',
|
||||
'border-b',
|
||||
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
|
||||
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
|
||||
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
|
||||
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
|
||||
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
|
||||
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
|
||||
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
|
||||
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
|
||||
|
||||
expect(tableContainer).toHaveClass(
|
||||
'overflow-hidden',
|
||||
'rounded-lg',
|
||||
'border',
|
||||
'border-divider-subtle',
|
||||
'text-xs',
|
||||
'leading-8',
|
||||
'font-medium',
|
||||
'text-text-tertiary',
|
||||
'uppercase',
|
||||
'bg-background-default',
|
||||
)
|
||||
expect(container.querySelector('tbody tr')).toHaveClass(
|
||||
'h-8',
|
||||
expect(tableShell?.tagName).toBe('TABLE')
|
||||
expect(header?.tagName).toBe('THEAD')
|
||||
expect(body?.tagName).toBe('TBODY')
|
||||
expect(row?.tagName).toBe('TR')
|
||||
expect(head?.tagName).toBe('TH')
|
||||
expect(cell?.tagName).toBe('TD')
|
||||
expect(tableShell).toHaveClass(
|
||||
'w-full',
|
||||
'table-fixed',
|
||||
'border-collapse',
|
||||
'caption-bottom',
|
||||
)
|
||||
expect(head).toHaveClass(
|
||||
'h-9',
|
||||
'px-4',
|
||||
'py-2',
|
||||
'system-sm-medium-uppercase',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
expect(row).toHaveClass(
|
||||
'border-b',
|
||||
'border-divider-subtle',
|
||||
'hover:bg-background-default-hover',
|
||||
)
|
||||
expect(cell).toHaveClass(
|
||||
'h-12',
|
||||
'min-w-0',
|
||||
'px-4',
|
||||
'py-2',
|
||||
)
|
||||
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
|
||||
expect(screen.getAllByText('R-001')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,7 +17,7 @@ import { environmentId, environmentName } from '../../environment'
|
||||
import { releaseDeploymentAction } from '../../release-action'
|
||||
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME } from '../list-styles'
|
||||
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
|
||||
|
||||
type EnvironmentOption = AppDeployEnvironment & {
|
||||
id: string
|
||||
@ -132,7 +132,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('versions.moreActions')}
|
||||
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
|
||||
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@ -21,12 +21,18 @@ import {
|
||||
DetailListState,
|
||||
} from '../common'
|
||||
import {
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
RELEASE_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
} from '../list-styles'
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCard,
|
||||
DetailTableCardList,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../table'
|
||||
import {
|
||||
RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from '../table-styles'
|
||||
import { DeployReleaseMenu } from './deploy-release-menu'
|
||||
import { DeployedToBadge } from './deployed-to-badge'
|
||||
import { getReleaseDeployments } from './release-deployments'
|
||||
@ -46,9 +52,9 @@ function ReleaseHistoryTableSkeleton() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<DetailTableCard key={key}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@ -64,40 +70,44 @@ function ReleaseHistoryTableSkeleton() {
|
||||
<ReleaseDeploymentsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
))}
|
||||
</div>
|
||||
</DetailTableCardList>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('versions.col.release')}</div>
|
||||
<div>{t('versions.col.createdAt')}</div>
|
||||
<div>{t('versions.col.author')}</div>
|
||||
<div>{t('versions.col.deployedTo')}</div>
|
||||
<div className="text-right">{t('versions.col.action')}</div>
|
||||
</div>
|
||||
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<DetailTable>
|
||||
<DetailTableHeader>
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
|
||||
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<DetailTableRow key={key}>
|
||||
<DetailTableCell>
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<ReleaseDeploymentsSkeleton />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
))}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -113,14 +123,14 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows,
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
|
||||
|
||||
return (
|
||||
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<DetailTableCard key={release.id}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@ -157,10 +167,10 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DetailTableCardList>
|
||||
)
|
||||
}
|
||||
|
||||
@ -243,22 +253,24 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
|
||||
deployedToHasError={deployedToHasError}
|
||||
/>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('versions.col.release')}</div>
|
||||
<div>{t('versions.col.createdAt')}</div>
|
||||
<div>{t('versions.col.author')}</div>
|
||||
<div>{t('versions.col.deployedTo')}</div>
|
||||
<div className="text-right">{t('versions.col.action')}</div>
|
||||
</div>
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||
<DetailTable>
|
||||
<DetailTableHeader>
|
||||
<DetailTableRow>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
|
||||
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
|
||||
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||
|
||||
return (
|
||||
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
return (
|
||||
<DetailTableRow key={release.id}>
|
||||
<DetailTableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
@ -271,14 +283,14 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
|
||||
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-w-0 system-sm-regular text-text-secondary">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="system-sm-regular text-text-secondary">
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
</div>
|
||||
<div className="min-w-0 truncate system-sm-regular text-text-secondary">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="truncate system-sm-regular text-text-secondary">
|
||||
{row.createdBy?.name ?? '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={releaseDeployments}
|
||||
@ -287,15 +299,17 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
|
||||
loadFailedLabel={t('common.loadFailed')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell>
|
||||
<div className="flex justify-end">
|
||||
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
})}
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user