diff --git a/src/modules/bkn-project/components/ProjectCard.module.css b/src/modules/bkn-project/components/ProjectCard.module.css new file mode 100644 index 00000000..2581b70b --- /dev/null +++ b/src/modules/bkn-project/components/ProjectCard.module.css @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2026 OpenBKN + * SPDX-License-Identifier: LicenseRef-OpenBKN + * Licensed under the OpenBKN License, a modified Apache 2.0 with Additional + * Conditions. See LICENSE for the full text. + */ + +/* The knowledge network card, so both lists look and behave alike. */ +.card { + composes: card from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; + --network-accent: var(--color-text-link); + min-height: 188px; +} + +.archived { + --network-accent: var(--color-border); + opacity: 0.65; +} + +.header { + composes: header from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.titleRow { + composes: titleRow from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.iconBox { + composes: iconBox from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; + color: var(--color-text-link); +} + +.titleContent { + composes: titleContent from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.titleText { + composes: titleText from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.moreButton { + composes: moreButton from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.cardMenu { + composes: cardMenu from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.footer { + composes: footer from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.footerLeft { + composes: footerLeft from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.metaItem { + composes: metaItem from "../../knowledge-network/components/network/KnowledgeNetworkCard.module.css"; +} + +.statusRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 22px; + margin-top: 6px; + font-size: 12px; +} + +.statusRow :global(.ant-tag) { + margin-inline-end: 0; +} + +.facts { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 12px 18px 12px 70px; + font-size: 13px; + line-height: 20px; +} + +.fact { + display: flex; + gap: 10px; + min-width: 0; +} + +.fact dt { + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.fact dd { + display: flex; + flex: 1; + align-items: center; + gap: 6px; + min-width: 0; + margin: 0; + color: var(--color-text-secondary); +} + +.ellipsis { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sameNetwork { + flex-shrink: 0; + margin-inline-end: 0; +} + +.muted { + color: var(--color-text-tertiary); +} diff --git a/src/modules/bkn-project/components/ProjectCard.test.tsx b/src/modules/bkn-project/components/ProjectCard.test.tsx new file mode 100644 index 00000000..ed883dce --- /dev/null +++ b/src/modules/bkn-project/components/ProjectCard.test.tsx @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2026 OpenBKN + * SPDX-License-Identifier: LicenseRef-OpenBKN + * Licensed under the OpenBKN License, a modified Apache 2.0 with Additional + * Conditions. See LICENSE for the full text. + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Project } from "@/modules/bkn-project/types/project"; + +const getStatusMock = vi.hoisted(() => vi.fn()); +const translate = vi.hoisted( + () => (key: string, options?: Record) => + options ? `${key}:${JSON.stringify(options)}` : key, +); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: translate }), +})); + +vi.mock("@/modules/bkn-project/services/publish.service", () => ({ + getProjectStatus: getStatusMock, +})); + +import { ProjectCard } from "./ProjectCard"; + +const project: Project = { + archived: false, + baselineNetworkId: "orders", + branch: "project/p1", + catalogIds: ["c1", "c2"], + createTime: 1, + id: "p1", + knId: "orders", + name: "Orders", + otherProjects: 0, + owner: { id: "me", type: "user" }, + updateTime: 1, +}; + +const release = { + knVersion: 3, + networkId: "orders", + rolledBack: false, + running: null, + version: "1.2.0", +}; + +function renderCard(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe("ProjectCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows where the project stands, its network and its data sources", async () => { + getStatusMock.mockResolvedValue({ release: null, stage: "check_needs_attention" }); + renderCard(); + + expect( + await screen.findByText("bknProject.list.card.stages.check_needs_attention"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Orders network" })).toHaveAttribute( + "href", + "/knowledge-network/workspace/orders/overview", + ); + // A catalog the caller cannot read keeps its id. + expect( + screen.getByText("ERPbknProject.list.card.separatorc2", { normalizer: (text) => text }), + ).toBeInTheDocument(); + expect(screen.getByText("bknProject.list.ownerYou")).toBeInTheDocument(); + }); + + it("names the published version in the stage of a published project", async () => { + getStatusMock.mockResolvedValue({ release, stage: "published" }); + renderCard(); + + expect( + await screen.findByText('bknProject.release.publish.publishedTag:{"version":"1.2.0"}'), + ).toBeInTheDocument(); + expect(screen.queryByText(/lastRelease/)).toBeNull(); + }); + + it("names the last release of a project that went on after it", async () => { + getStatusMock.mockResolvedValue({ release, stage: "deciding" }); + renderCard(); + + expect(await screen.findByText("bknProject.list.card.stages.deciding")).toBeInTheDocument(); + expect( + screen.getByText('bknProject.list.card.lastRelease:{"version":"1.2.0"}'), + ).toBeInTheDocument(); + }); + + it("says the network was switched back, with the version it runs", async () => { + getStatusMock.mockResolvedValue({ + release: { ...release, rolledBack: true, running: { knVersion: 2, label: "v1.1.0" } }, + stage: "published", + }); + renderCard(); + + expect( + await screen.findByText('bknProject.release.publish.rolledBack:{"version":"v1.1.0"}'), + ).toBeInTheDocument(); + }); + + it("shows an archived project as archived without reading its status", async () => { + renderCard({ archived: true }); + + expect(screen.getByText("bknProject.list.card.stages.archived")).toBeInTheDocument(); + await waitFor(() => expect(getStatusMock).not.toHaveBeenCalled()); + }); + + it("drops the release of a card archived while it is shown", async () => { + getStatusMock.mockResolvedValue({ release, stage: "deciding" }); + const { rerender } = renderCard(); + expect(await screen.findByText(/lastRelease/)).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByText("bknProject.list.card.stages.archived")).toBeInTheDocument(); + expect(screen.queryByText(/lastRelease/)).toBeNull(); + }); + + it("still shows the card when its status cannot be read", async () => { + getStatusMock.mockRejectedValue(new Error("down")); + renderCard(); + + await waitFor(() => expect(getStatusMock).toHaveBeenCalledWith("p1")); + expect(screen.getByText("Orders")).toBeInTheDocument(); + expect(screen.queryByText(/stages\./)).toBeNull(); + }); +}); diff --git a/src/modules/bkn-project/components/ProjectCard.tsx b/src/modules/bkn-project/components/ProjectCard.tsx new file mode 100644 index 00000000..8d423db3 --- /dev/null +++ b/src/modules/bkn-project/components/ProjectCard.tsx @@ -0,0 +1,244 @@ +/** + * Copyright (c) 2026 OpenBKN + * SPDX-License-Identifier: LicenseRef-OpenBKN + * Licensed under the OpenBKN License, a modified Apache 2.0 with Additional + * Conditions. See LICENSE for the full text. + */ + +import { + ClockCircleOutlined, + EllipsisOutlined, + ProjectOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { Dropdown, Tag, Tooltip, type MenuProps } from "antd"; +import type { TFunction } from "i18next"; +import dayjs from "dayjs"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { LightStatusTag, type LightStatusTone } from "@/framework/ui/common/LightStatusTag"; +import { versionName } from "@/modules/bkn-project/components/release/releaseMessages"; +import { getProjectStatus } from "@/modules/bkn-project/services/publish.service"; +import type { Project } from "@/modules/bkn-project/types/project"; +import type { ProjectStage, ProjectStatus } from "@/modules/bkn-project/types/publish"; +import { networkWorkspacePath } from "@/modules/bkn-project/types/workspace"; + +import styles from "./ProjectCard.module.css"; + +const STAGE_TONES: Record = { + archived: "neutral", + check_needs_attention: "warning", + check_not_started: "info", + checking: "info", + confirmed: "info", + deciding: "info", + draft_failed: "error", + drafting: "info", + no_draft: "neutral", + published: "success", + ready_to_confirm: "info", +}; + +const formatTime = (value: number) => dayjs(value).format("YYYY/MM/DD HH:mm"); + +type ProjectCardProps = { + catalogNames: Record; + /** The caller owns the project and may archive or restore it. */ + isOwner: boolean; + networkName?: string; + onArchive: (project: Project) => void; + onOpen: (project: Project) => void; + onUnarchive: (project: Project) => void; + project: Project; +}; + +/** What the project's release says on the card: one tag, or a tag and a line beside it. */ +function releaseView(t: TFunction, status: ProjectStatus) { + const { release, stage } = status; + if (!release) { + return null; + } + if (release.rolledBack && release.running) { + return { + text: t("bknProject.release.publish.rolledBack", { + version: versionName(t, release.running.knVersion, release.running.label), + }), + tone: "warning" as const, + }; + } + // A published project says it in its stage tag; one that went on after it names the release. + return stage === "published" + ? null + : { text: t("bknProject.list.card.lastRelease", { version: release.version }), tone: null }; +} + +/** A project in the list, as the knowledge network list shows a network. */ +export function ProjectCard({ + catalogNames, + isOwner, + networkName, + onArchive, + onOpen, + onUnarchive, + project, +}: ProjectCardProps) { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + + // An archived project's stage is "archived"; the card says so without asking. + useEffect(() => { + if (project.archived) { + return; + } + let active = true; + getProjectStatus(project.id) + .then((next) => active && setStatus(next)) + // Without its status the card still opens the project; the workspace shows the failure. + .catch(() => active && setStatus(null)); + return () => { + active = false; + }; + }, [project.archived, project.id, project.updateTime]); + + const stage: ProjectStage | null = project.archived ? "archived" : (status?.stage ?? null); + // A card archived while shown keeps its last status; archived cards say only that they are. + const release = status && !project.archived ? releaseView(t, status) : null; + const publishedVersion = stage === "published" ? status?.release?.version : undefined; + + const menuItems: MenuProps["items"] = [ + { key: "open", label: t("bknProject.list.card.open") }, + ...(isOwner + ? [ + project.archived + ? { key: "unarchive", label: t("bknProject.list.unarchive") } + : { key: "archive", label: t("bknProject.list.archive") }, + ] + : []), + ]; + + const catalogs = project.catalogIds.map((id) => catalogNames[id] ?? id); + + return ( +
onOpen(project)} + onKeyDown={(event) => { + if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + onOpen(project); + } + }} + role="button" + tabIndex={0} + > +
+
+ + + +
+ +
{project.name}
+
+
+ {stage ? ( + + {publishedVersion + ? t("bknProject.release.publish.publishedTag", { version: publishedVersion }) + : t(`bknProject.list.card.stages.${stage}`)} + + ) : null} + {release?.tone ? ( + {release.text} + ) : release ? ( + {release.text} + ) : null} +
+
+
+ { + domEvent.stopPropagation(); + if (key === "archive") { + onArchive(project); + } else if (key === "unarchive") { + onUnarchive(project); + } else { + onOpen(project); + } + }, + }} + overlayClassName={styles.cardMenu} + // The menu popup is a React child of the card: clicks inside it must not open the project. + popupRender={(menu) =>
event.stopPropagation()}>{menu}
} + trigger={["click"]} + > + +
+
+ +
+
+
{t("bknProject.list.card.network")}
+
+ {/* The project's network keeps its versions under one id: the link opens it. */} + event.stopPropagation()} + to={networkWorkspacePath(project.knId)} + > + {networkName ?? project.knId} + + {project.otherProjects > 0 ? ( + + + {t("bknProject.list.sameNetwork", { count: project.otherProjects })} + + + ) : null} +
+
+
+
{t("bknProject.list.card.catalogs")}
+
+ {catalogs.length > 0 ? ( + + + {catalogs.join(t("bknProject.list.card.separator"))} + + + ) : ( + — + )} +
+
+
+ +
+ + + + {isOwner ? t("bknProject.list.ownerYou") : project.owner.id} + + + + + + {formatTime(project.updateTime)} + + +
+
+ ); +} diff --git a/src/modules/bkn-project/locales/en-US.ts b/src/modules/bkn-project/locales/en-US.ts index a39abeda..a4e000d9 100644 --- a/src/modules/bkn-project/locales/en-US.ts +++ b/src/modules/bkn-project/locales/en-US.ts @@ -11,9 +11,6 @@ export const bknProjectEnUS = { description: "Each project brings requirements, ontology and releases together for one business knowledge network.", list: { - cardTitle: "Projects", - cardTip: - "Several projects may change one knowledge network; publishing checks whether another project has changed its live version.", searchPlaceholder: "Search project names", showArchived: "Show archived", create: "New project", @@ -28,18 +25,34 @@ export const bknProjectEnUS = { emptyDescription: "Create a project to build a business knowledge network from structured data.", emptyFiltered: "No matching projects", - columns: { - project: "Project", - network: "Target knowledge network", - catalogs: "Data catalogs", + card: { + open: "Open project", + more: "More actions", + network: "Knowledge network", + catalogs: "Data sources", + separator: ", ", owner: "Owner", updated: "Updated", - actions: "Actions", + lastRelease: "Last published v{{version}}", + stages: { + archived: "Archived", + no_draft: "Not drafted", + drafting: "Drafting", + draft_failed: "Drafting failed", + deciding: "Modeling", + ready_to_confirm: "Model to confirm", + check_not_started: "Data check pending", + checking: "Checking data", + check_needs_attention: "Data check needs attention", + confirmed: "Ready to publish", + published: "Published", + }, }, sameNetwork: "Same network · {{count}} other projects", sameNetwork_one: "Same network · {{count}} other project", sameNetwork_other: "Same network · {{count}} other projects", - archivedTag: "Archived", + sameNetworkTip: + "Several projects may change one knowledge network; publishing checks whether another project has changed its live version.", ownerYou: "You", archive: "Archive", unarchive: "Restore", diff --git a/src/modules/bkn-project/locales/zh-CN.ts b/src/modules/bkn-project/locales/zh-CN.ts index de4f1549..d58d2c4e 100644 --- a/src/modules/bkn-project/locales/zh-CN.ts +++ b/src/modules/bkn-project/locales/zh-CN.ts @@ -10,8 +10,6 @@ export const bknProjectZhCN = { title: "项目", description: "每个项目把需求、本体和发布组织在一起,面向一个业务知识网络。", list: { - cardTitle: "项目", - cardTip: "多个项目可以修改同一个知识网络;发布时会检查线上版本是否已被其他项目改变。", searchPlaceholder: "搜索项目名", showArchived: "显示已归档", create: "新建项目", @@ -25,18 +23,33 @@ export const bknProjectZhCN = { emptyTitle: "还没有项目", emptyDescription: "新建一个项目,从结构化数据开始构建业务知识网络。", emptyFiltered: "没有匹配的项目", - columns: { - project: "项目", - network: "目标业务知识网络", - catalogs: "引用的数据目录", + card: { + open: "打开项目", + more: "更多操作", + network: "知识网络", + catalogs: "数据来源", + separator: "、", owner: "负责人", updated: "更新时间", - actions: "操作", + lastRelease: "最近发布 v{{version}}", + stages: { + archived: "已归档", + no_draft: "待起草", + drafting: "起草中", + draft_failed: "起草失败", + deciding: "建模中", + ready_to_confirm: "待确认模型", + check_not_started: "待数据校验", + checking: "数据校验中", + check_needs_attention: "数据校验未通过", + confirmed: "待发布", + published: "已发布", + }, }, sameNetwork: "同网络 · 另有 {{count}} 个项目", sameNetwork_one: "同网络 · 另有 {{count}} 个项目", sameNetwork_other: "同网络 · 另有 {{count}} 个项目", - archivedTag: "已归档", + sameNetworkTip: "多个项目可以修改同一个知识网络;发布时会检查线上版本是否已被其他项目改变。", ownerYou: "我", archive: "归档", unarchive: "恢复", diff --git a/src/modules/bkn-project/scenes/ProjectListScene.module.css b/src/modules/bkn-project/scenes/ProjectListScene.module.css index 292c6b2f..9e2851b3 100644 --- a/src/modules/bkn-project/scenes/ProjectListScene.module.css +++ b/src/modules/bkn-project/scenes/ProjectListScene.module.css @@ -5,95 +5,73 @@ * Conditions. See LICENSE for the full text. */ -/* The platform's white page surface, so the list sits on it as other modules do. */ +/* The knowledge network list's page, toolbar and card grid, so both lists read alike. */ .page { - composes: surface from "../../../framework/ui/common/BusinessPageTemplate.module.css"; - /* Fills the content area, so no shell grey shows below a short list. */ - flex: 1; - padding: 24px 28px; + composes: page from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; - padding-bottom: 16px; - border-bottom: 1px solid var(--color-divider); +.toolbar { + composes: toolbar from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.title { - margin: 0; - color: var(--color-text-primary); - font-size: 22px; - font-weight: 700; +.toolbarLeft { + composes: toolbarLeft from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.intro { - margin: 8px 0 0; - max-width: 880px; - color: var(--color-text-secondary); - font-size: 14px; - line-height: 1.7; +.toolbarRight { + composes: toolbarRight from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.toolbar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 16px; +.toolbarButton { + composes: toolbarButton from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.search { - width: 280px; +.searchInput { + composes: searchInput from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.alert { - margin-bottom: 16px; +.searchIcon { + composes: searchIcon from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.cardHead { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; +.toolbarActions { + composes: toolbarActions from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.cardTitle { - color: var(--color-text-primary); - font-weight: 600; +.iconButton { + composes: iconButton from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.stack { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 3px; +.contentArea { + composes: contentArea from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.name { - color: var(--color-text-primary); - font-weight: 600; +.loadingWrapper { + composes: loadingWrapper from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.id { - color: var(--color-text-tertiary); - font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - font-size: 11.5px; +.listContent { + composes: listContent from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.muted { - color: var(--color-text-tertiary); +.grid { + composes: grid from "../../knowledge-network/scenes/KnowledgeNetworkListScene.module.css"; } -.archivedRow { - opacity: 0.6; +.alert { + margin-top: 14px; } .pager { display: flex; + flex-shrink: 0; + align-items: center; justify-content: flex-end; gap: 8px; - margin-top: 12px; + margin-top: 16px; +} + +.total { + color: var(--color-text-tertiary); + font-size: 13px; } diff --git a/src/modules/bkn-project/scenes/ProjectListScene.test.tsx b/src/modules/bkn-project/scenes/ProjectListScene.test.tsx index af86becb..4993d43e 100644 --- a/src/modules/bkn-project/scenes/ProjectListScene.test.tsx +++ b/src/modules/bkn-project/scenes/ProjectListScene.test.tsx @@ -16,6 +16,7 @@ const createProjectMock = vi.hoisted(() => vi.fn()); const listNetworksMock = vi.hoisted(() => vi.fn()); const setArchivedMock = vi.hoisted(() => vi.fn()); const listCatalogsMock = vi.hoisted(() => vi.fn()); +const getStatusMock = vi.hoisted(() => vi.fn()); const messageMock = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); const confirmMock = vi.hoisted(() => vi.fn()); const newProjectModalRenderMock = vi.hoisted(() => vi.fn()); @@ -45,6 +46,10 @@ vi.mock("@/modules/bkn-project/services/project.service", () => ({ setProjectArchived: setArchivedMock, })); +vi.mock("@/modules/bkn-project/services/publish.service", () => ({ + getProjectStatus: getStatusMock, +})); + vi.mock("@/shared/catalog", () => ({ catalogListAllQuery: (overrides: object) => overrides, listCatalogs: listCatalogsMock, @@ -100,6 +105,17 @@ function project(overrides: Partial): Project { }; } +function card(name: string) { + return screen.getByText(name).closest("article") as HTMLElement; +} + +/** Opens a card's menu and returns the names of its actions. */ +async function openMenu(name: string) { + fireEvent.click(within(card(name)).getByRole("button", { name: "bknProject.list.card.more" })); + const menus = await screen.findAllByRole("menu"); + return menus[menus.length - 1]; +} + function axiosError(code: string) { return { isAxiosError: true, response: { data: { error_code: code, description: code } } }; } @@ -125,6 +141,7 @@ describe("ProjectListScene", () => { ); listNetworksMock.mockResolvedValue([{ id: "orders", name: "Orders network" }]); listCatalogsMock.mockResolvedValue({ items: [{ id: "c1", name: "ERP" }], total: 1 }); + getStatusMock.mockResolvedValue({ release: null, stage: "deciding" }); }); afterEach(() => { @@ -169,19 +186,25 @@ describe("ProjectListScene", () => { }); renderScene(); - const mine = (await screen.findByText("Orders")).closest("tr") as HTMLElement; + await screen.findByText("Orders"); + const mine = card("Orders"); expect(within(mine).getByText("Orders network")).toBeInTheDocument(); expect(within(mine).getByText("ERP")).toBeInTheDocument(); expect(within(mine).getByText('bknProject.list.sameNetwork:{"count":1}')).toBeInTheDocument(); - expect(within(mine).getByText("bknProject.list.archive")).toBeInTheDocument(); - - const theirs = screen.getByText("Receipts").closest("tr") as HTMLElement; - expect(within(theirs).queryByText("bknProject.list.archive")).toBeNull(); + expect( + await within(mine).findByText("bknProject.list.card.stages.deciding"), + ).toBeInTheDocument(); + expect( + within(await openMenu("Orders")).getByText("bknProject.list.archive"), + ).toBeInTheDocument(); + + const theirs = card("Receipts"); expect(within(theirs).getByText("someone")).toBeInTheDocument(); + expect(within(await openMenu("Receipts")).queryByText("bknProject.list.archive")).toBeNull(); expect(listProjectsMock).toHaveBeenCalledWith({ archived: "false", cursor: undefined, - limit: 20, + limit: 24, name: "", }); }); @@ -204,10 +227,6 @@ describe("ProjectListScene", () => { fireEvent.change(screen.getByPlaceholderText("bknProject.list.searchPlaceholder"), { target: { value: " ord " }, }); - fireEvent.keyDown(screen.getByPlaceholderText("bknProject.list.searchPlaceholder"), { - key: "Enter", - keyCode: 13, - }); await waitFor(() => expect(listProjectsMock).toHaveBeenLastCalledWith(expect.objectContaining({ name: "ord" })), ); @@ -258,14 +277,12 @@ describe("ProjectListScene", () => { expect(await screen.findByText("opened overview")).toBeInTheDocument(); }); - it("links each project name to its overview", async () => { + it("opens a project's overview from its card", async () => { listProjectsMock.mockResolvedValue({ items: [project({})], nextCursor: null, total: 1 }); renderScene(); - expect((await screen.findByText("Orders")).closest("a")).toHaveAttribute( - "href", - "/bkn-project/p1/overview", - ); + fireEvent.click(await screen.findByText("Orders")); + expect(await screen.findByText("opened overview")).toBeInTheDocument(); }); it("pages with the cursor the backend returns, and back", async () => { @@ -341,7 +358,7 @@ describe("ProjectListScene", () => { renderScene(); expect(await screen.findByText('bknProject.list.total:{"count":7}')).toBeInTheDocument(); - fireEvent.click(screen.getByText("bknProject.list.refresh")); + fireEvent.click(screen.getByRole("button", { name: "bknProject.list.refresh" })); expect(await screen.findByText("bknProject.list.loadFailed")).toBeInTheDocument(); expect(screen.getByText('bknProject.list.total:{"count":0}')).toBeInTheDocument(); @@ -360,12 +377,13 @@ describe("ProjectListScene", () => { setArchivedMock.mockResolvedValue(project({})); renderScene(); - fireEvent.click(await screen.findByText("bknProject.list.archive")); + await screen.findByText("Orders"); + fireEvent.click(within(await openMenu("Orders")).getByText("bknProject.list.archive")); const { onOk } = confirmMock.mock.calls[0][0] as { onOk: () => Promise }; await onOk(); expect(setArchivedMock).toHaveBeenCalledWith("p1", true); - fireEvent.click(screen.getByText("bknProject.list.unarchive")); + fireEvent.click(within(await openMenu("Old")).getByText("bknProject.list.unarchive")); await waitFor(() => expect(setArchivedMock).toHaveBeenLastCalledWith("p2", false)); expect(messageMock.success).toHaveBeenCalledWith("bknProject.list.unarchiveSuccess"); }); diff --git a/src/modules/bkn-project/scenes/ProjectListScene.tsx b/src/modules/bkn-project/scenes/ProjectListScene.tsx index 05faecaa..1e04fa4a 100644 --- a/src/modules/bkn-project/scenes/ProjectListScene.tsx +++ b/src/modules/bkn-project/scenes/ProjectListScene.tsx @@ -5,40 +5,38 @@ * Conditions. See LICENSE for the full text. */ -import { InfoCircleOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"; -import { Alert, Checkbox, Input, Tag, Tooltip } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import dayjs from "dayjs"; +import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +import { Alert, Checkbox, Input, Spin, Tooltip } from "antd"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAppServices } from "@/framework/context/use-app-services"; +import { useDebouncedValue } from "@/framework/hooks/use-debounced-value"; import { extractRequestErrorDetails } from "@/framework/request/error-message"; import { getMockUserRole } from "@/framework/runtime/mock-user"; import { AppButton } from "@/framework/ui/common/AppButton"; -import { AppTable } from "@/framework/ui/common/AppTable"; import { EmptyStatePanel } from "@/framework/ui/common/EmptyStatePanel"; import { requestFailureText } from "@/modules/bkn-project/components/publish/messages"; import { NewProjectModal } from "@/modules/bkn-project/components/NewProjectModal"; +import { ProjectCard } from "@/modules/bkn-project/components/ProjectCard"; import { listProjectNetworks, listProjects, setProjectArchived, } from "@/modules/bkn-project/services/project.service"; import type { Project, ProjectCatalog, ProjectNetwork } from "@/modules/bkn-project/types/project"; -import { networkWorkspacePath, projectSectionPath } from "@/modules/bkn-project/types/workspace"; +import { projectSectionPath } from "@/modules/bkn-project/types/workspace"; import { catalogListAllQuery, listCatalogs } from "@/shared/catalog"; import styles from "./ProjectListScene.module.css"; -export const PROJECT_PAGE_SIZE = 20; +/** Fills whole rows of the card grid at its common widths. */ +export const PROJECT_PAGE_SIZE = 24; /** bkn-project answers a cursor it did not issue, or one gone stale, with this code. */ const INVALID_CURSOR_CODE = "BknProject.InvalidParameter.Cursor"; -const formatTime = (value: number) => dayjs(value).format("YYYY/MM/DD HH:mm"); - export function ProjectListScene() { const { t } = useTranslation(); const { message, modal, runtimeConfig } = useAppServices(); @@ -142,10 +140,14 @@ export function ProjectListScene() { const resetPaging = () => setCursors([undefined]); - const handleSearch = (value: string) => { - setName(value.trim()); - resetPaging(); - }; + // Searches as the name is typed, as the knowledge network list does. + const searchedName = useDebouncedValue(keyword).trim(); + useEffect(() => { + if (searchedName !== name) { + setName(searchedName); + setCursors([undefined]); + } + }, [name, searchedName]); const handleShowArchived = (checked: boolean) => { setShowArchived(checked); @@ -188,125 +190,56 @@ export function ProjectListScene() { }); }; - const columns: ColumnsType = [ - { - title: t("bknProject.list.columns.project"), - dataIndex: "name", - width: 260, - render: (value: string, record) => ( -
- - {value} - - {record.id} - {record.archived ? {t("bknProject.list.archivedTag")} : null} -
- ), - }, - { - title: t("bknProject.list.columns.network"), - dataIndex: "knId", - width: 260, - render: (value: string, record) => ( -
- {/* The project's network keeps its versions under one id: the link opens it. */} - {networkNames[value] ?? value} - {value} - {record.otherProjects > 0 ? ( - - {t("bknProject.list.sameNetwork", { count: record.otherProjects })} - - ) : null} -
- ), - }, - { - title: t("bknProject.list.columns.catalogs"), - dataIndex: "catalogIds", - width: 200, - render: (value: string[]) => - value.length > 0 ? ( -
- {value.map((id) => ( - {catalogNames[id] ?? id} - ))} -
- ) : ( - — - ), - }, - { - title: t("bknProject.list.columns.owner"), - dataIndex: "owner", - width: 140, - render: (_value, record) => - record.owner.id === currentUserId ? ( - t("bknProject.list.ownerYou") - ) : ( - {record.owner.id} - ), - }, - { - title: t("bknProject.list.columns.updated"), - dataIndex: "updateTime", - width: 160, - render: (value: number) => formatTime(value), - }, - { - title: t("bknProject.list.columns.actions"), - key: "actions", - width: 100, - render: (_value, record) => { - if (record.owner.id !== currentUserId) { - return null; - } - return record.archived ? ( - void changeArchived(record, false)}> - {t("bknProject.list.unarchive")} - - ) : ( - handleArchive(record)}> - {t("bknProject.list.archive")} - - ); - }, - }, - ]; - const filtered = name !== "" || showArchived; + const openProject = (project: Project) => { + void navigate(projectSectionPath(project.id, "overview")); + }; + return (
-
-
-

{t("bknProject.title")}

-

{t("bknProject.description")}

-
- {canCreateProject ? ( - } type="primary" onClick={openCreate}> - {t("bknProject.list.create")} - - ) : null} -
-
- setKeyword(event.target.value)} - onSearch={handleSearch} - placeholder={t("bknProject.list.searchPlaceholder")} - value={keyword} - /> - handleShowArchived(event.target.checked)} - > - {t("bknProject.list.showArchived")} - - } loading={loading} onClick={() => void loadProjects()}> - {t("bknProject.list.refresh")} - +
+ {canCreateProject ? ( + } + onClick={openCreate} + type="primary" + > + {t("bknProject.list.create")} + + ) : null} +
+
+ setKeyword(event.target.value)} + placeholder={t("bknProject.list.searchPlaceholder")} + prefix={} + value={keyword} + /> + handleShowArchived(event.target.checked)} + > + {t("bknProject.list.showArchived")} + +
+ + + +
+
{loadError ? ( @@ -324,47 +257,54 @@ export function ProjectListScene() { /> ) : null} -
- {t("bknProject.list.cardTitle")} - - - - {t("bknProject.list.total", { count: total })} -
- - - columns={columns} - dataSource={items} - loading={loading} - locale={{ - emptyText: filtered ? ( - - ) : ( - - ), - }} - pagination={false} - rowClassName={(record) => (record.archived ? styles.archivedRow : "")} - rowKey="id" - scroll={{ x: 1100 }} - /> +
+ +
+ {!loadError && !loading && items.length === 0 ? ( + filtered ? ( + + ) : ( + + ) + ) : ( +
+ {items.map((project) => ( + void changeArchived(record, false)} + project={project} + /> + ))} +
+ )} +
+
- {page > 0 || nextCursor ? (
- setCursors(cursors.slice(0, -1))}> - {t("bknProject.list.prevPage")} - - nextCursor && setCursors([...cursors, nextCursor])} - > - {t("bknProject.list.nextPage")} - + {t("bknProject.list.total", { count: total })} + {page > 0 || nextCursor ? ( + <> + setCursors(cursors.slice(0, -1))}> + {t("bknProject.list.prevPage")} + + nextCursor && setCursors([...cursors, nextCursor])} + > + {t("bknProject.list.nextPage")} + + + ) : null}
- ) : null} +
{canCreateProject ? (