Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Use Monaco Editor for JSON query editor with syntax highlighting auto-completion, [PR-158](https://github.com/reductstore/web-console/issues/158)
- Support for changing replication mode (enabled, paused, disabled), [PR-157](https://github.com/reductstore/web-console/pull/157)

### Fixed

- Include full query options when generating download/share/preview links, [PR-160](https://github.com/reductstore/web-console/pull/160)

## 1.12.1 - 2025-11-17

### Fixed
Expand Down
26 changes: 18 additions & 8 deletions src/Components/JsonEditor/JsonQueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,29 +284,39 @@ export function JsonQueryEditor({
return <span>Validating...</span>;
}

if (validationStatus === ValidationStatus.Valid) {
if (validationStatus === ValidationStatus.Warning) {
return (
<>
<span className="jsonQueryEditorValidationOk">✓</span>
<span>Valid condition</span>
<span className="jsonQueryEditorValidationWarning">!</span>
<span>{validationError || "Validation skipped"}</span>
</>
);
}

if (validationStatus === ValidationStatus.Warning) {
if (validationStatus === ValidationStatus.Invalid) {
return (
<>
<span className="jsonQueryEditorValidationWarning">!</span>
<span>{validationError || "Validation skipped"}</span>
<span className="jsonQueryEditorValidationError">✗</span>
<span>{validationError || "Invalid condition"}</span>
</>
);
}

if (validationStatus === ValidationStatus.Invalid) {
// validation is tested with limit = 1 but an error may still occur when executing the query
if (validationStatus === ValidationStatus.Valid && !error) {
return (
<>
<span className="jsonQueryEditorValidationOk">✓</span>
<span>Valid condition</span>
</>
);
}

if (error) {
return (
<>
<span className="jsonQueryEditorValidationError">✗</span>
<span>{validationError || "Invalid condition"}</span>
<span>{error}</span>
</>
);
}
Expand Down
43 changes: 41 additions & 2 deletions src/Components/RecordPreview/RecordPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mount, ReactWrapper } from "enzyme";
import { Button, Typography, Alert } from "antd";
import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons";
import RecordPreview from "./RecordPreview";
import { Bucket } from "reduct-js";
import { Bucket, QueryOptions } from "reduct-js";
import { mockJSDOM } from "../../Helpers/TestHelpers";

describe("RecordPreview", () => {
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("RecordPreview", () => {
wrapper = mount(<RecordPreview {...largeFileProps} />);

expect(wrapper.find(Typography.Text).last().text()).toContain(
"Preview not available",
"Text size exceeds 10 MB limit for preview",
);
});

Expand Down Expand Up @@ -112,6 +112,45 @@ describe("RecordPreview", () => {
});
});

it("should pass query context to createQueryLink", async () => {
const queryOptions = new QueryOptions();
queryOptions.when = { "&label": { $eq: "test" } };
queryOptions.strict = true;
queryOptions.head = false;

(mockBucket.createQueryLink as jest.Mock).mockResolvedValue(
"http://test-url",
);
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
text: () => Promise.resolve("test content"),
});

wrapper = mount(
<RecordPreview
{...defaultProps}
queryStart={10n}
queryEnd={20n}
queryOptions={queryOptions}
recordIndex={2}
/>,
);

await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();

expect(mockBucket.createQueryLink).toHaveBeenCalledWith(
"test-entry",
10n,
20n,
queryOptions,
2,
expect.any(Date),
"test.txt",
"http://localhost:8383",
);
});

it("should show error on fetch failure", async () => {
(mockBucket.createQueryLink as jest.Mock).mockRejectedValue(
new Error("Network error"),
Expand Down
53 changes: 40 additions & 13 deletions src/Components/RecordPreview/RecordPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Button, Typography, Image, Spin, Alert, Card } from "antd";
import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons";
import { Bucket } from "reduct-js";
import { Bucket, QueryOptions } from "reduct-js";

import "./RecordPreview.css";

Expand All @@ -11,12 +11,16 @@ interface RecordPreviewProps {
fileName: string;
entryName: string;
timestamp: bigint;
queryStart?: bigint;
queryEnd?: bigint;
queryOptions?: QueryOptions;
recordIndex?: number;
bucket: Bucket;
apiUrl: string;
}

const MAX_TEXT_SIZE = 1024 * 1024; // 1MB limit for text preview
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB limit for image preview
const MAX_LOAD_SIZE = 10 * 1024 * 1024; // 10 MB limit for loading preview
const AUTO_PREVIEW_SIZE = 1 * 1024 * 1024; // 1 MB limit for auto preview

const isImageType = (contentType: string): boolean => {
return contentType.startsWith("image/");
Expand All @@ -39,24 +43,39 @@ const RecordPreview: React.FC<RecordPreviewProps> = ({
fileName,
entryName,
timestamp,
queryStart,
queryEnd,
queryOptions,
recordIndex,
bucket,
apiUrl,
}) => {
const [isPreviewVisible, setIsPreviewVisible] = useState(true);
const shouldAutoPreview = size <= AUTO_PREVIEW_SIZE;
const [isPreviewVisible, setIsPreviewVisible] = useState(shouldAutoPreview);
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const canPreview = (): boolean => {
if (isImageType(contentType)) {
return size <= MAX_IMAGE_SIZE;
return size <= MAX_LOAD_SIZE;
}
if (isTextType(contentType)) {
return size <= MAX_TEXT_SIZE;
return size <= MAX_LOAD_SIZE;
}
return false;
};

const getUnavailableReason = (): string => {
if (isImageType(contentType) && size > MAX_LOAD_SIZE) {
return `Image size exceeds ${MAX_LOAD_SIZE / (1024 * 1024)} MB limit for preview`;
}
if (isTextType(contentType) && size > MAX_LOAD_SIZE) {
return `Text size exceeds ${MAX_LOAD_SIZE / (1024 * 1024)} MB limit for preview`;
}
return `Format is not supported for preview`;
};

const fetchPreview = async (abortSignal?: AbortSignal) => {
if (!canPreview()) {
setError("File too large or unsupported format for preview");
Expand All @@ -70,10 +89,10 @@ const RecordPreview: React.FC<RecordPreviewProps> = ({
const expireAt = new Date(Date.now() + 60 * 60 * 1000);
const generatedQueryLink = await bucket.createQueryLink(
entryName,
timestamp,
undefined,
undefined,
0,
queryStart ?? timestamp,
queryEnd,
queryOptions,
recordIndex ?? 0,
expireAt,
fileName,
apiUrl,
Expand Down Expand Up @@ -130,15 +149,15 @@ const RecordPreview: React.FC<RecordPreviewProps> = ({
};

useEffect(() => {
if (canPreview() && !previewContent && !error) {
if (canPreview() && shouldAutoPreview && !previewContent && !error) {
const abortController = new AbortController();
fetchPreview(abortController.signal);

return () => {
abortController.abort();
};
}
}, []); // abort controller on unmount
}, []);

const renderPreviewContent = () => {
if (isLoading) {
Expand Down Expand Up @@ -190,12 +209,14 @@ const RecordPreview: React.FC<RecordPreviewProps> = ({
return (
<Card size="small" className="recordPreviewCard">
<Typography.Text type="secondary">
Preview not available for this file type or size
{getUnavailableReason()}
</Typography.Text>
</Card>
);
}

const showSizeWarning = !isPreviewVisible && !shouldAutoPreview;

return (
<Card size="small" className="recordPreviewCard">
<div className="previewHeader">
Expand All @@ -209,6 +230,12 @@ const RecordPreview: React.FC<RecordPreviewProps> = ({
{isPreviewVisible ? "Hide Preview" : "Show Preview"}
</Button>
</div>
{showSizeWarning && (
<Typography.Text type="secondary">
Content larger than {(AUTO_PREVIEW_SIZE / (1024 * 1024)).toFixed(1)}{" "}
MB. Click "Show Preview" to load.
</Typography.Text>
)}
{isPreviewVisible && (
<div className="previewContent">{renderPreviewContent()}</div>
)}
Expand Down
6 changes: 3 additions & 3 deletions src/Components/ShareLinkModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import ShareLinkModal from "./ShareLinkModal";
import ShareLinkModal, { ShareLinkRecord } from "./ShareLinkModal";

describe("ShareLinkModal", () => {
const mockOnGenerate = jest.fn();
const mockOnCancel = jest.fn();
const record = {
key: 0,
const record: ShareLinkRecord = {
key: "0",
contentType: "application/octet-stream",
};

Expand Down
60 changes: 41 additions & 19 deletions src/Components/ShareLinkModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
Modal,
Button,
Expand All @@ -15,10 +15,15 @@ import dayjs, { Dayjs } from "dayjs";
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
import { getExtensionFromContentType } from "../Helpers/contentType";

export interface ShareLinkRecord {
key: string;
contentType: string | undefined;
}

interface ShareLinkModalProps {
open: boolean;
entryName: string;
record: any;
record: ShareLinkRecord | null;
onGenerate: (expireAt: Date, fileName: string) => Promise<string>;
onCancel: () => void;
errorMessage?: string | null;
Expand All @@ -45,20 +50,35 @@ export default function ShareLinkModal({
onCancel,
errorMessage,
}: ShareLinkModalProps) {
if (!record) return null;

const [expireAt, setExpireAt] = useState<Dayjs | null>(
dayjs().add(24, "hour"),
);
const [activePreset, setActivePreset] = useState<string | null>("24h");

const ext = getExtensionFromContentType(record.contentType || "");
const defaultFileName = `${entryName}-${record.key}${ext}`;

const [fileName, setFileName] = useState(defaultFileName);
const [fileName, setFileName] = useState("");
const [link, setLink] = useState<string>("");
const [loading, setLoading] = useState(false);

const ext = record
? getExtensionFromContentType(record.contentType || "")
: "";
const defaultFileName = record ? `${entryName}-${record.key}${ext}` : "";

// Reset state when record changes or modal opens with new record
useEffect(() => {
if (open && record) {
const newExt = getExtensionFromContentType(record.contentType || "");
const newDefaultFileName = `${entryName}-${record.key}${newExt}`;
setFileName(newDefaultFileName);
setLink("");
setExpireAt(dayjs().add(24, "hour"));
setActivePreset("24h");
}
}, [open, record, entryName]);

if (!record) return null;

const canCopy = !!navigator.clipboard;

const handleCancel = () => {
setLink("");
setFileName(defaultFileName);
Expand All @@ -73,8 +93,8 @@ export default function ShareLinkModal({
try {
await navigator.clipboard.writeText(linkToCopy);
message.success("Link copied to clipboard");
} catch {
message.error("Failed to copy link");
} catch (err) {
message.error("Failed to copy link to clipboard");
}
};

Expand All @@ -84,7 +104,7 @@ export default function ShareLinkModal({
try {
const generated = await onGenerate(expireAt.toDate(), fileName.trim());
setLink(generated);
await handleCopy(generated);
if (canCopy) await handleCopy(generated);
} catch (err) {
console.error("Failed to generate share link:", err);
message.error("Failed to generate link");
Expand Down Expand Up @@ -181,13 +201,15 @@ export default function ShareLinkModal({
<Typography.Text strong>Shareable Link:</Typography.Text>
<Input value={link} readOnly data-testid="generated-link" />
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy()}
data-testid="copy-button"
>
Copy
</Button>
{canCopy && (
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy()}
data-testid="copy-button"
>
Copy
</Button>
)}
<Button
icon={<LinkOutlined />}
href={link}
Expand Down
4 changes: 0 additions & 4 deletions src/Views/BucketPanel/EntryDetail.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,6 @@
max-width: var(--entry-detail-max-width);
}

.jsonEditor .CodeMirror {
height: auto;
}

.jsonExample {
display: block;
margin-top: var(--entry-detail-inner-spacing);
Expand Down
Loading