refactor(board): restore simple add item workflow with SelectableCard and clean modal flow
This commit is contained in:
4 files changed
+164
-1849
No files matched your search
@@ -1,304 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { Button, Center, MantineProvider } from "@mantine/core";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"widget.common.error.noIntegration": "No integration selected for this widget",
|
||||
"common.error": "An unexpected error occurred",
|
||||
"common.action.retry": "Retry",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
// Suppress console.error during error boundary tests to avoid noisy output
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Simulates the Live Preview Error Boundary container from item-select-modal.tsx
|
||||
const LivePreviewContainer = ({
|
||||
options,
|
||||
previewIntegrationIds,
|
||||
hasIntegrationSupport,
|
||||
integrationsRequired,
|
||||
onResetCallback,
|
||||
widgetComponent,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
previewIntegrationIds: string[];
|
||||
hasIntegrationSupport: boolean;
|
||||
integrationsRequired: boolean;
|
||||
onResetCallback?: () => void;
|
||||
widgetComponent: (props: { options: Record<string, unknown>; integrationIds: string[] }) => JSX.Element;
|
||||
}) => {
|
||||
return (
|
||||
<div data-testid="outer-modal-pane">
|
||||
<div data-testid="outer-header">Add Widget Header</div>
|
||||
<div data-testid="live-preview-frame">
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
reset();
|
||||
onResetCallback?.();
|
||||
}}
|
||||
resetKeys={[options, previewIntegrationIds]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<Center h="100%" p="md" data-testid="preview-error-fallback">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Throw
|
||||
error={new NoIntegrationSelectedError()}
|
||||
when={hasIntegrationSupport && previewIntegrationIds.length === 0 && integrationsRequired}
|
||||
/>
|
||||
{widgetComponent({ options, integrationIds: previewIntegrationIds })}
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
</div>
|
||||
<div data-testid="outer-footer">Add Widget Footer</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Advanced Add App - Live Preview Error Boundary Isolation", () => {
|
||||
it("isolates runtime errors thrown inside the preview widget without crashing outer modal components", async () => {
|
||||
const BuggyWidget = () => {
|
||||
throw new Error("Widget rendering failure in preview");
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{ title: "Test" }}
|
||||
previewIntegrationIds={["integration-1"]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={() => <BuggyWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Outer components are preserved and not destroyed by the error
|
||||
expect(host.querySelector("[data-testid='outer-modal-pane']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='outer-header']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='outer-footer']")).not.toBeNull();
|
||||
|
||||
// The error fallback is rendered inside the preview frame
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Widget rendering failure in preview");
|
||||
});
|
||||
|
||||
it("throws and catches NoIntegrationSelectedError when integration is required and previewIntegrationIds is empty", async () => {
|
||||
const WorkingWidget = () => <div data-testid="widget-content">Healthy Widget</div>;
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={() => <WorkingWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Error fallback is active
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("No integration selected for this widget");
|
||||
expect(host.querySelector("[data-testid='widget-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders healthy widget without throwing when integrationsRequired is false even if previewIntegrationIds is empty", async () => {
|
||||
const OptionalIntegrationWidget = ({ integrationIds }: { integrationIds: string[] }) => (
|
||||
<div data-testid="widget-content">Widget rendered with {integrationIds.length} integrations</div>
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={false}
|
||||
widgetComponent={({ integrationIds }) => <OptionalIntegrationWidget integrationIds={integrationIds} />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='widget-content']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Widget rendered with 0 integrations");
|
||||
});
|
||||
|
||||
it("renders healthy widget without throwing when widget has no integration support", async () => {
|
||||
const StandaloneWidget = () => <div data-testid="standalone-widget">Clock Widget</div>;
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={false}
|
||||
integrationsRequired={false}
|
||||
widgetComponent={() => <StandaloneWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='standalone-widget']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Clock Widget");
|
||||
});
|
||||
|
||||
it("automatically resets and recovers when previewIntegrationIds or options change via resetKeys", async () => {
|
||||
const DynamicWidget = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
return <div data-testid="healthy-widget">Active with: {integrationIds.join(", ")}</div>;
|
||||
};
|
||||
|
||||
const TestHarness = () => {
|
||||
const [integrationIds, setIntegrationIds] = useState<string[]>([]);
|
||||
const [options, setOptions] = useState<Record<string, unknown>>({ query: "default" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="connect-service-btn" onClick={() => setIntegrationIds(["new-service-id"])}>
|
||||
Connect
|
||||
</button>
|
||||
<button data-testid="change-options-btn" onClick={() => setOptions({ query: "updated" })}>
|
||||
Change Option
|
||||
</button>
|
||||
<LivePreviewContainer
|
||||
options={options}
|
||||
previewIntegrationIds={integrationIds}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={({ integrationIds: ids }) => <DynamicWidget integrationIds={ids} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<TestHarness />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Initial state: no integration -> error boundary is showing
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='healthy-widget']")).toBeNull();
|
||||
|
||||
// Click connect button -> previewIntegrationIds updates -> error boundary automatically resets
|
||||
const connectBtn = host.querySelector("[data-testid='connect-service-btn']");
|
||||
await act(async () => {
|
||||
(connectBtn as HTMLButtonElement)?.click();
|
||||
});
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='healthy-widget']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Active with: new-service-id");
|
||||
});
|
||||
|
||||
it("recovers from error state when user clicks retry button in WidgetError", async () => {
|
||||
let shouldFail = true;
|
||||
const onResetCallback = vi.fn();
|
||||
|
||||
const FlakyWidget = () => {
|
||||
if (shouldFail) {
|
||||
throw new Error("Temporary network glitch");
|
||||
}
|
||||
return <div data-testid="recovered-widget">Recovered Content</div>;
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={["integration-1"]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
onResetCallback={onResetCallback}
|
||||
widgetComponent={() => <FlakyWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Temporary network glitch");
|
||||
|
||||
// Heal the condition
|
||||
shouldFail = false;
|
||||
|
||||
// Click the retry button rendered by BaseWidgetError
|
||||
const retryButton = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.toLowerCase().includes("retry"),
|
||||
);
|
||||
expect(retryButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
retryButton?.click();
|
||||
});
|
||||
|
||||
expect(onResetCallback).toHaveBeenCalledOnce();
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='recovered-widget']")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
// Helper functions mirroring the exact business logic from item-select-modal.tsx
|
||||
export const getCompatibleIntegrations = <
|
||||
TIntegration extends { id: string; name: string; kind: IntegrationKind; url?: string },
|
||||
>(
|
||||
supportedIntegrations: readonly IntegrationKind[] | undefined,
|
||||
integrationData: TIntegration[] | undefined,
|
||||
) => {
|
||||
if (!supportedIntegrations) return [];
|
||||
return (integrationData ?? [])
|
||||
.filter((i) => supportedIntegrations.includes(i.kind))
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
kind: i.kind,
|
||||
url: i.url,
|
||||
}));
|
||||
};
|
||||
|
||||
export const sanitizeIntegrationIds = (selectedIds: string[], compatibleIntegrations: { id: string }[]): string[] => {
|
||||
const compatibleIds = new Set(compatibleIntegrations.map((i) => i.id));
|
||||
return selectedIds.filter((id) => compatibleIds.has(id));
|
||||
};
|
||||
|
||||
export const resolvePreviewIntegrationIds = ({
|
||||
validIntegrationIds,
|
||||
supportsMock,
|
||||
mockIntegration,
|
||||
}: {
|
||||
validIntegrationIds: string[];
|
||||
supportsMock: boolean;
|
||||
mockIntegration: { id: string } | null | undefined;
|
||||
}): string[] => {
|
||||
if (validIntegrationIds.length > 0) return validIntegrationIds;
|
||||
if (supportsMock && mockIntegration) return [mockIntegration.id];
|
||||
return [];
|
||||
};
|
||||
|
||||
export const computeInitialIntegrationIds = ({
|
||||
hasIntegrationSupport,
|
||||
maxIntegrations = Infinity,
|
||||
compatibleIntegrations,
|
||||
}: {
|
||||
hasIntegrationSupport: boolean;
|
||||
maxIntegrations?: number;
|
||||
compatibleIntegrations: { id: string }[];
|
||||
}): string[] => {
|
||||
if (!hasIntegrationSupport) return [];
|
||||
if (compatibleIntegrations.length === 1 && compatibleIntegrations[0]) {
|
||||
return [compatibleIntegrations[0].id];
|
||||
}
|
||||
return compatibleIntegrations.slice(0, maxIntegrations).map((i) => i.id);
|
||||
};
|
||||
|
||||
export const appendOrReplaceIntegrationId = ({
|
||||
currentIds,
|
||||
newId,
|
||||
maxIntegrations = Infinity,
|
||||
}: {
|
||||
currentIds: string[];
|
||||
newId: string;
|
||||
maxIntegrations?: number;
|
||||
}): string[] => {
|
||||
return [...(maxIntegrations > 1 ? currentIds : []), newId];
|
||||
};
|
||||
|
||||
describe("Advanced Add App - Integration ID Sanitization & Mock Fallback", () => {
|
||||
describe("getCompatibleIntegrations", () => {
|
||||
it("returns empty array when supportedIntegrations is undefined or empty", () => {
|
||||
const integrations = [{ id: "1", name: "Sonarr", kind: "sonarr" as IntegrationKind }];
|
||||
|
||||
expect(getCompatibleIntegrations(undefined, integrations)).toEqual([]);
|
||||
expect(getCompatibleIntegrations([], integrations)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters integration data to only include kinds supported by the widget", () => {
|
||||
const integrations = [
|
||||
{ id: "sonarr-1", name: "Sonarr Instance", kind: "sonarr" as IntegrationKind, url: "http://sonarr.local" },
|
||||
{ id: "radarr-1", name: "Radarr Instance", kind: "radarr" as IntegrationKind, url: "http://radarr.local" },
|
||||
{ id: "plex-1", name: "Plex Server", kind: "plex" as IntegrationKind, url: "http://plex.local" },
|
||||
];
|
||||
|
||||
const compatible = getCompatibleIntegrations(["sonarr", "radarr"], integrations);
|
||||
expect(compatible).toEqual([
|
||||
{ id: "sonarr-1", name: "Sonarr Instance", kind: "sonarr", url: "http://sonarr.local" },
|
||||
{ id: "radarr-1", name: "Radarr Instance", kind: "radarr", url: "http://radarr.local" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when integrationData is undefined", () => {
|
||||
expect(getCompatibleIntegrations(["sonarr"], undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeIntegrationIds", () => {
|
||||
it("filters out IDs that are not present in compatible integrations", () => {
|
||||
const compatible = [{ id: "valid-1" }, { id: "valid-2" }];
|
||||
const selected = ["valid-1", "stale-deleted-id", "valid-2", "incompatible-service-id"];
|
||||
|
||||
const sanitized = sanitizeIntegrationIds(selected, compatible);
|
||||
expect(sanitized).toEqual(["valid-1", "valid-2"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no selected IDs match compatible integrations", () => {
|
||||
const compatible = [{ id: "valid-1" }];
|
||||
const selected = ["unknown-1", "unknown-2"];
|
||||
|
||||
expect(sanitizeIntegrationIds(selected, compatible)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when compatible list is empty", () => {
|
||||
expect(sanitizeIntegrationIds(["any-id"], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePreviewIntegrationIds (mock fallback logic)", () => {
|
||||
const mockIntegration = { id: "mock-instance-id" };
|
||||
|
||||
it("uses validIntegrationIds when at least one valid integration is selected", () => {
|
||||
const previewIds = resolvePreviewIntegrationIds({
|
||||
validIntegrationIds: ["real-service-1"],
|
||||
supportsMock: true,
|
||||
mockIntegration,
|
||||
});
|
||||
|
||||
expect(previewIds).toEqual(["real-service-1"]);
|
||||
});
|
||||
|
||||
it("falls back to mock integration when validIntegrationIds is empty and widget supports mock", () => {
|
||||
const previewIds = resolvePreviewIntegrationIds({
|
||||
validIntegrationIds: [],
|
||||
supportsMock: true,
|
||||
mockIntegration,
|
||||
});
|
||||
|
||||
expect(previewIds).toEqual(["mock-instance-id"]);
|
||||
});
|
||||
|
||||
it("returns empty array when validIntegrationIds is empty and widget does NOT support mock", () => {
|
||||
const previewIds = resolvePreviewIntegrationIds({
|
||||
validIntegrationIds: [],
|
||||
supportsMock: false,
|
||||
mockIntegration,
|
||||
});
|
||||
|
||||
expect(previewIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when validIntegrationIds is empty and mockIntegration is null/undefined", () => {
|
||||
const previewIds = resolvePreviewIntegrationIds({
|
||||
validIntegrationIds: [],
|
||||
supportsMock: true,
|
||||
mockIntegration: null,
|
||||
});
|
||||
|
||||
expect(previewIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers real integrations over mock integration even if mock is available", () => {
|
||||
const previewIds = resolvePreviewIntegrationIds({
|
||||
validIntegrationIds: ["plex-1", "jellyfin-1"],
|
||||
supportsMock: true,
|
||||
mockIntegration,
|
||||
});
|
||||
|
||||
expect(previewIds).toEqual(["plex-1", "jellyfin-1"]);
|
||||
expect(previewIds).not.toContain("mock-instance-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInitialIntegrationIds", () => {
|
||||
it("returns empty array when widget has no integration support", () => {
|
||||
const initial = computeInitialIntegrationIds({
|
||||
hasIntegrationSupport: false,
|
||||
compatibleIntegrations: [{ id: "1" }],
|
||||
});
|
||||
expect(initial).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-selects single choice when exactly 1 compatible integration exists", () => {
|
||||
const initial = computeInitialIntegrationIds({
|
||||
hasIntegrationSupport: true,
|
||||
maxIntegrations: 5,
|
||||
compatibleIntegrations: [{ id: "only-one" }],
|
||||
});
|
||||
expect(initial).toEqual(["only-one"]);
|
||||
});
|
||||
|
||||
it("slices compatible integrations up to maxIntegrations when multiple exist", () => {
|
||||
const compatible = [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }];
|
||||
|
||||
const singleSelect = computeInitialIntegrationIds({
|
||||
hasIntegrationSupport: true,
|
||||
maxIntegrations: 1,
|
||||
compatibleIntegrations: compatible,
|
||||
});
|
||||
expect(singleSelect).toEqual(["1"]);
|
||||
|
||||
const multiSelect = computeInitialIntegrationIds({
|
||||
hasIntegrationSupport: true,
|
||||
maxIntegrations: 2,
|
||||
compatibleIntegrations: compatible,
|
||||
});
|
||||
expect(multiSelect).toEqual(["1", "2"]);
|
||||
|
||||
const unlimited = computeInitialIntegrationIds({
|
||||
hasIntegrationSupport: true,
|
||||
compatibleIntegrations: compatible,
|
||||
});
|
||||
expect(unlimited).toEqual(["1", "2", "3", "4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendOrReplaceIntegrationId (inline create & demo service connection)", () => {
|
||||
it("replaces existing selection when maxIntegrations is 1 (single-select)", () => {
|
||||
const result = appendOrReplaceIntegrationId({
|
||||
currentIds: ["old-id"],
|
||||
newId: "new-id",
|
||||
maxIntegrations: 1,
|
||||
});
|
||||
expect(result).toEqual(["new-id"]);
|
||||
});
|
||||
|
||||
it("appends to existing selection when maxIntegrations is greater than 1 (multi-select)", () => {
|
||||
const result = appendOrReplaceIntegrationId({
|
||||
currentIds: ["service-1", "service-2"],
|
||||
newId: "service-3",
|
||||
maxIntegrations: 3,
|
||||
});
|
||||
expect(result).toEqual(["service-1", "service-2", "service-3"]);
|
||||
});
|
||||
|
||||
it("appends to empty selection correctly for both single and multi-select", () => {
|
||||
expect(
|
||||
appendOrReplaceIntegrationId({
|
||||
currentIds: [],
|
||||
newId: "created-id",
|
||||
maxIntegrations: 1,
|
||||
}),
|
||||
).toEqual(["created-id"]);
|
||||
|
||||
expect(
|
||||
appendOrReplaceIntegrationId({
|
||||
currentIds: [],
|
||||
newId: "created-id",
|
||||
maxIntegrations: 5,
|
||||
}),
|
||||
).toEqual(["created-id"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user