Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/javascript/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,12 @@ declare global {
var jQuery: any
}

declare module "lib/policy" {
interface Policy {
isAllowed: (permission: string, options?: Record<string, unknown>) => boolean
}
export const policy: Policy
export function setPolicy(newPolicy: Policy): void
}

export {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import "@testing-library/jest-dom/vitest"
import ReplicaItem from "./item"

// ─── Module mocks ─────────────────────────────────────────────────────────────

vi.mock("react-router-dom", () => ({
Link: ({ to, children }: any) => <a href={to}>{children}</a>,
}))

const mockIsAllowed = vi.hoisted(() => vi.fn().mockReturnValue(true))

vi.mock("lib/policy", () => ({
policy: { isAllowed: mockIsAllowed },
}))

// ─── Fixtures ─────────────────────────────────────────────────────────────────

const mockReplica = {
id: "rep-1",
name: "my-replica",
share_id: "share-1",
replica_state: "active",
status: "available",
isFetching: false,
isDeleting: false,
}

const mockShare = { id: "share-1", name: "my-share" }

const defaultProps = {
replica: mockReplica,
share: mockShare,
handleDelete: vi.fn(),
reloadReplica: vi.fn(),
promoteReplica: vi.fn(),
resyncReplica: vi.fn(),
}

const renderComponent = (props = {}) =>
render(
<table>
<tbody>
<ReplicaItem {...defaultProps} {...props} />
</tbody>
</table>
)

// ─── Tests ────────────────────────────────────────────────────────────────────

describe("ReplicaItem", () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAllowed.mockReturnValue(true)
})

// ── Row rendering ──────────────────────────────────────────────────────────

describe("Row rendering", () => {
it("renders replica name as link", () => {
renderComponent()
const link = screen.getByText("my-replica")
expect(link.closest("a")).toHaveAttribute("href", "/replicas/rep-1/show")
})

it("renders replica id as info-text when name is set", () => {
renderComponent()
expect(screen.getByText("rep-1")).toBeInTheDocument()
})

it("renders replica id as link when name is not set", () => {
renderComponent({ replica: { ...mockReplica, name: undefined } })
const link = screen.getByText("rep-1")
expect(link.closest("a")).toHaveAttribute("href", "/replicas/rep-1/show")
})

it("renders source share name and id", () => {
renderComponent()
expect(screen.getByText("my-share")).toBeInTheDocument()
expect(screen.getByText("share-1")).toBeInTheDocument()
})

it("renders share_id only when share is not found", () => {
renderComponent({ share: undefined })
expect(screen.getByText("share-1")).toBeInTheDocument()
})

it("renders replica_state", () => {
renderComponent()
expect(screen.getByText("active")).toBeInTheDocument()
})

it("renders status", () => {
renderComponent()
expect(screen.getByText("available")).toBeInTheDocument()
})

it("shows spinner when status is creating", () => {
renderComponent({ replica: { ...mockReplica, status: "creating" } })
expect(document.querySelector(".spinner")).toBeInTheDocument()
})

it("does not show spinner when status is available", () => {
renderComponent()
expect(document.querySelector(".spinner")).not.toBeInTheDocument()
})

it("applies 'updating' class when isFetching", () => {
renderComponent({ replica: { ...mockReplica, isFetching: true } })
expect(document.querySelector("tr.updating")).toBeInTheDocument()
})
})

// ── Actions menu ───────────────────────────────────────────────────────────

describe("Actions menu", () => {
it("renders actions dropdown when any permission is granted", () => {
renderComponent()
expect(document.querySelector(".btn-group")).toBeInTheDocument()
})

it("renders Delete, Activate, Re-sync, and Error Log actions", () => {
renderComponent()
expect(screen.getByText("Delete")).toBeInTheDocument()
expect(screen.getByText("Activate")).toBeInTheDocument()
expect(screen.getByText("Re-sync")).toBeInTheDocument()
expect(screen.getByText("Error Log")).toBeInTheDocument()
})

it("does not render actions menu when no permissions granted", () => {
mockIsAllowed.mockReturnValue(false)
renderComponent()
expect(document.querySelector(".btn-group")).not.toBeInTheDocument()
})

it("Error Log links to /replicas/:id/error-log", () => {
renderComponent()
const errorLogLink = screen.getByText("Error Log").closest("a")
expect(errorLogLink).toHaveAttribute("href", "/replicas/rep-1/error-log")
})

it("does not render Delete when status is creating", () => {
renderComponent({ replica: { ...mockReplica, status: "creating" } })
expect(screen.queryByText("Delete")).not.toBeInTheDocument()
})
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useCallback } from "react"
import { Link } from "react-router-dom"
import { policy } from "lib/policy"
import React from "react"

// availability_zone: "qa-de-1a"
// cached_object_type: "share_replica"
Expand All @@ -14,35 +14,58 @@ import React from "react"
// share_server_id: "dd24c960-27d0-4db0-94de-628177c17700"
// status: "available"
// updated_at: "2019-02-06T13:39:55.000000"
// ─── Types ────────────────────────────────────────────────────────────────────

const ReplicaItem = ({
interface Replica {
id: string
name?: string
share_id: string
replica_state?: string
status?: string
isFetching?: boolean
isDeleting?: boolean
}

interface Share {
id: string
name: string
}

interface ReplicaItemProps {
replica: Replica
share?: Share | string
handleDelete: (id: string) => void
reloadReplica: (id: string) => void
promoteReplica: () => void
resyncReplica: () => void
}

// ─── Component ────────────────────────────────────────────────────────────────

const ReplicaItem: React.FC<ReplicaItemProps> = ({
replica,
share,
handleDelete,
reloadReplica,
promoteReplica,
resyncReplica,
}) => {
React.useEffect(() => {
if (["replication_change", "creating"].indexOf(replica.status) < 0) return
useEffect(() => {
if (["replication_change", "creating"].indexOf(replica.status ?? "") < 0) return
const polling = setInterval(() => reloadReplica(replica.id), 10000)

return () => clearInterval(polling)
}, [reloadReplica, replica.id, replica.status])

const canI = React.useCallback(
(permission) =>
policy.isAllowed(`shared_filesystem_storage:replica_${permission}`, {
replica,
}),
const canI = useCallback(
(permission: string) =>
policy.isAllowed(`shared_filesystem_storage:replica_${permission}`, { replica }),
[replica]
)

return (
<tr className={replica.isFetching || replica.isDeleting ? "updating" : ""}>
<td>
<Link to={`/replicas/${replica.id}/show`}>
{replica.name || replica.id}
</Link>
<Link to={`/replicas/${replica.id}/show`}>{replica.name || replica.id}</Link>
{replica.name && (
<>
<br />
Expand All @@ -51,7 +74,7 @@ const ReplicaItem = ({
)}
</td>
<td>
{share ? (
{share && typeof share === "object" ? (
<div>
{share.name}
<br />
Expand All @@ -61,17 +84,13 @@ const ReplicaItem = ({
replica.share_id
)}
</td>

<td>{replica.replica_state}</td>
<td>
{replica.status == "creating" && <span className="spinner" />}{" "}
{replica.status === "creating" && <span className="spinner" />}{" "}
{replica.status}
</td>
<td className="snug">
{(canI("promote") ||
canI("delete") ||
canI("resync") ||
canI("get_error_log")) && (
{(canI("promote") || canI("delete") || canI("resync") || canI("get_error_log")) && (
<div className="btn-group">
<button
className="btn btn-default btn-sm dropdown-toggle"
Expand All @@ -82,7 +101,7 @@ const ReplicaItem = ({
<i className="fa fa-cog"></i>
</button>
<ul className="dropdown-menu dropdown-menu-right" role="menu">
{canI("delete") && replica.status != "creating" && (
{canI("delete") && replica.status !== "creating" && (
<li>
<a
href="#"
Expand All @@ -98,11 +117,11 @@ const ReplicaItem = ({
{canI("promote") && (
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault()
promoteReplica()
}}
href="#"
>
Activate
</a>
Expand All @@ -111,21 +130,19 @@ const ReplicaItem = ({
{canI("resync") && (
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault()
resyncReplica()
}}
href="#"
>
Re-sync
</a>
</li>
)}
{canI("get_error_log") && (
<li>
<Link to={`/replicas/${replica.id}/error-log`}>
Error Log
</Link>
<Link to={`/replicas/${replica.id}/error-log`}>Error Log</Link>
</li>
)}
</ul>
Expand All @@ -135,4 +152,5 @@ const ReplicaItem = ({
</tr>
)
}

export default ReplicaItem

This file was deleted.

Loading
Loading