Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { Tooltip } from "src/system-components";
import type { JsonPreviewEntry } from "src/utils/jsonPreview";

const MAX_VALUE_CHARS = 40;
// Below this a shortened value says nothing, so a narrow cap spends its width on fewer badges
// rather than on unreadable ones.
const MIN_VALUE_CHARS = 12;
// Widest advance of one glyph in the 12px badge font, measured across real payloads. Estimating
// high costs a little column width; estimating low wraps the preview onto a second line.
const CHAR_WIDTH = 7.3;
Expand All @@ -36,18 +39,25 @@ const MAX_WIDTH = 380;

type Props = {
readonly entries: Array<JsonPreviewEntry>;
/** Lower cap for a table that carries more than one of these side by side. */
readonly maxWidth?: number;
readonly onExpand: () => void;
};

export const JsonPreviewBadges = ({ entries, onExpand }: Props) => {
export const JsonPreviewBadges = ({ entries, maxWidth = MAX_WIDTH, onExpand }: Props) => {
const { t: translate } = useTranslation(["components", "common"]);

const previews = entries.map((entry) => {
const full =
entry.itemCount === undefined
? entry.value.replaceAll(/\s+/gu, " ")
: translate("jsonPreview.items", { count: entry.itemCount });
const text = full.length > MAX_VALUE_CHARS ? `${full.slice(0, MAX_VALUE_CHARS)}…` : full;
// A badge sets its value on one line, so one long entry — a dotted class path, a URL — would
// carry the whole preview past the cap on its own, whatever the loop below decides to show.
const labelChars = entry.label === undefined ? 0 : entry.label.length + 1;
const roomChars = Math.floor((maxWidth - BADGE_CHROME) / CHAR_WIDTH) - labelChars;
const valueChars = Math.max(MIN_VALUE_CHARS, Math.min(MAX_VALUE_CHARS, roomChars));
const text = full.length > valueChars ? `${full.slice(0, valueChars)}…` : full;

// A summarised or shortened value only hints at what it stands for, so the editor still has
// something to add even when every entry is on screen.
Expand All @@ -67,7 +77,7 @@ export const JsonPreviewBadges = ({ entries, onExpand }: Props) => {

if (
visibleEntries.length > 0 &&
usedWidth + badgeWidth + (needsButton ? MORE_BUTTON_WIDTH : 0) > MAX_WIDTH
usedWidth + badgeWidth + (needsButton ? MORE_BUTTON_WIDTH : 0) > maxWidth
) {
break;
}
Expand All @@ -79,19 +89,39 @@ export const JsonPreviewBadges = ({ entries, onExpand }: Props) => {
const hiddenCount = previews.length - visibleEntries.length;
// Nothing left to reveal means nothing to expand.
const canExpand = hiddenCount > 0 || visibleEntries.some((preview) => preview.isElided);
const previewWidth = Math.min(usedWidth + (canExpand ? MORE_BUTTON_WIDTH : 0), MAX_WIDTH);
const previewWidth = Math.min(usedWidth + (canExpand ? MORE_BUTTON_WIDTH : 0), maxWidth);
// The expand button shares the line, so a badge that filled the cap on its own would still carry
// the preview — and the column around it — past that cap.
const badgeMaxWidth = maxWidth - (canExpand ? MORE_BUTTON_WIDTH : 0);

return (
<HStack data-testid="json-preview-badges" gap={1} minW={`${previewWidth}px`}>
{visibleEntries.map(({ full, id, isComplex, label, text }) => (
<Tooltip content={full} disabled={text === full} key={id} portalled>
<Badge colorPalette="gray" gap={1} size="sm" variant="surface" whiteSpace="nowrap">
{/* The character budget above only estimates how wide a badge will be, and a font that
runs wider than the estimate would carry the badge — and the column holding it — past
the cap. maxW is what actually holds the line. */}
<Badge
colorPalette="gray"
gap={1}
maxW={`${badgeMaxWidth}px`}
overflow="hidden"
size="sm"
variant="surface"
whiteSpace="nowrap"
>
{label === undefined ? undefined : (
<Text as="span" color="fg.muted">
{label}:
</Text>
)}
<Text as="span" color={isComplex ? "fg.muted" : "fg"} fontFamily="mono">
<Text
as="span"
color={isComplex ? "fg.muted" : "fg"}
fontFamily="mono"
overflow="hidden"
textOverflow="ellipsis"
>
{text}
</Text>
</Badge>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,25 @@ describe("RenderedJsonField", () => {
expect(container).toBeEmptyDOMElement();
});

it.each([
["a collapsed preview", true],
["the open editor", false],
])("keeps the copy button out of the way in %s until hover or focus", (_label, collapsed) => {
// One copy button per row reads as chrome. It stays mounted rather than conditionally
// rendered, so it keeps its place in the tab order for a keyboard.
render(<RenderedJsonField collapsed={collapsed} content={{ score: 0.99 }} />, { wrapper: Wrapper });

expect(screen.getByTestId("json-clipboard-reveal")).toBeInTheDocument();
});

it("renders no copy button at all when the caller turns the clipboard off", () => {
render(<RenderedJsonField collapsed content={{ score: 0.99 }} enableClipboard={false} />, {
wrapper: Wrapper,
});

expect(screen.queryByTestId("json-clipboard-reveal")).not.toBeInTheDocument();
});

it("renders the editor when the caller never collapses it", () => {
render(<RenderedJsonField content={{ score: 0.99 }} />, { wrapper: Wrapper });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,20 @@ type Props = {
readonly enableClipboard?: boolean;
/** Replaces expanding in place — for a preview whose full value lives on another page. */
readonly onExpand?: () => void;
/** Narrows the collapsed preview, for a table that carries more than one of these per row. */
readonly previewMaxWidth?: number;
} & Omit<FlexProps, "content">;

type EditorInstance = Parameters<OnMount>[0];

const RenderedJsonField = ({ collapsed, content, enableClipboard = true, onExpand, ...rest }: Props) => {
const RenderedJsonField = ({
collapsed,
content,
enableClipboard = true,
onExpand,
previewMaxWidth,
...rest
}: Props) => {
const contentFormatted = JSON.stringify(content, undefined, 2);
const { t: translate } = useTranslation("common");
const { beforeMount, theme } = useMonacoTheme();
Expand Down Expand Up @@ -113,23 +122,36 @@ const RenderedJsonField = ({ collapsed, content, enableClipboard = true, onExpan
return undefined;
}

// A copy button on every row reads as chrome, so it waits for the pointer. It stays mounted
// rather than conditionally rendered, to keep its place in the tab order for a keyboard.
const clipboardButton = enableClipboard ? (
<ClipboardRoot value={contentFormatted}>
<ClipboardIconButton h={7} minW={7} />
</ClipboardRoot>
<Box
_focusWithin={{ opacity: 1 }}
_groupHover={{ opacity: 1 }}
data-testid="json-clipboard-reveal"
opacity={0}
>
<ClipboardRoot value={contentFormatted}>
<ClipboardIconButton h={7} minW={7} />
</ClipboardRoot>
</Box>
) : undefined;

if (showBadges) {
return (
<Flex alignItems="center" flex={1} gap={1} minW={`${MIN_WIDTH}px`} {...rest}>
<JsonPreviewBadges entries={previewEntries} onExpand={onExpand ?? (() => setIsExpanded(true))} />
<Flex alignItems="center" className="group" flex={1} gap={1} minW={`${MIN_WIDTH}px`} {...rest}>
<JsonPreviewBadges
entries={previewEntries}
maxWidth={previewMaxWidth}
onExpand={onExpand ?? (() => setIsExpanded(true))}
/>
{clipboardButton}
</Flex>
);
}

return (
<Flex flex={1} gap={2} minW={`${MIN_WIDTH}px`} {...rest}>
<Flex className="group" flex={1} gap={2} minW={`${MIN_WIDTH}px`} {...rest}>
<Box flex={1} minW={`${editorWidth}px`} ref={containerRef}>
<Editor
beforeMount={beforeMount}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Alert, Code } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";

type DiffUnavailableAlertProps = {
readonly reason?: string | null;
};

export const DiffUnavailableAlert = ({ reason }: DiffUnavailableAlertProps) => {
const { t: translate } = useTranslation("dag");

return (
<Alert.Root status="info" title={translate("versions.unavailable.title")}>
<Alert.Description>
{reason === null || reason === undefined ? (
translate("versions.unavailable.withoutReason")
) : (
<>
{translate("versions.unavailable.description")}{" "}
{/* A machine token, so it is shown as code rather than folded into a translated sentence. */}
<Code fontSize="sm">{reason}</Code>
</>
)}
</Alert.Description>
</Alert.Root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import RenderedJsonField from "src/components/RenderedJsonField";

import { MonoText } from "./MonoText";

// A scalar can still be long — a templated command, a doc string — and nothing below it is worth
// the vertical space of a whole cell.
const MAX_VALUE_LENGTH = 120;

// This table is the one place with two JSON previews per row, and the component's own cap leaves
// them demanding more width than the pane has. The rest of a value is one expand away.
const PREVIEW_MAX_WIDTH = 220;

type DiffValueProps = {
readonly value: unknown;
};

export const DiffValue = ({ value }: DiffValueProps) => {
// A side the change does not have is left blank: a dash would read as a value the version holds.
if (value === undefined) {
return undefined;
}

// A whole task added or removed carries its entire serialized payload. The JSON field names its
// top-level keys in badges and opens the editor in place, where a flattened string could only be
// cut short. An empty object or array has no entries to preview, so it falls through to the text
// below and shows as `{}` or `[]`.
if (typeof value === "object" && value !== null && Object.keys(value).length > 0) {
return <RenderedJsonField collapsed content={value} previewMaxWidth={PREVIEW_MAX_WIDTH} />;
}

// A stored null is a value; only an absent side gets the blank cell above. An empty string is
// quoted so it reads as a value rather than as a blank cell.
const text = typeof value === "string" ? value || '""' : JSON.stringify(value);

// Serialized values have no spaces to break at, so without this a single cell widens the table
// past the pane and the last ("after") column is clipped.
return (
<MonoText overflowWrap="anywhere">
{text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH)}…` : text}
</MonoText>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import "@testing-library/jest-dom/vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { Wrapper } from "src/utils/Wrapper";

import { MaxChangesField } from "./MaxChangesField";

const revealTruncationNotice = async () => {
const warning = screen.getByTestId("max-changes-truncated");

await act(async () => {
fireEvent.focus(warning);
fireEvent.pointerEnter(warning);
await vi.advanceTimersByTimeAsync(500);
});
};

// The test harness passes translation keys through untranslated, so assertions on chrome use the
// key and assertions on content use what the caller passed.
describe("MaxChangesField", () => {
afterEach(() => {
vi.useRealTimers();
});

it("says nothing about a limit while the comparison fits inside it", () => {
render(<MaxChangesField maxChanges={10} onCommit={vi.fn()} truncated={false} />, { wrapper: Wrapper });

expect(screen.queryByTestId("max-changes-truncated")).not.toBeInTheDocument();
});

it("keeps the truncation notice on the field, behind the warning", async () => {
// A banner over the table repeated itself on every comparison and sat nowhere near the bound
// that caused it; the field is the one control that can lift it.
vi.useFakeTimers();
render(<MaxChangesField maxChanges={10} onCommit={vi.fn()} truncated />, { wrapper: Wrapper });

expect(screen.queryByText("versions.limitReached.description")).not.toBeInTheDocument();

await revealTruncationNotice();

expect(screen.getByRole("tooltip")).toHaveTextContent("versions.limitReached.description");
});

it("names the endpoint's own limit when no bound was set", async () => {
vi.useFakeTimers();
render(<MaxChangesField onCommit={vi.fn()} truncated />, { wrapper: Wrapper });

await revealTruncationNotice();

expect(screen.getByRole("tooltip")).toHaveTextContent("versions.limitReached.descriptionDefault");
});
});
Loading
Loading