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
25 changes: 25 additions & 0 deletions src/components/manage/change-warning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import clsx from "clsx";

/** A caution line under a repository's change preview; renders nothing without a warning. */
export function ChangeWarning({
className,
warning,
}: Readonly<{
className?: string;
warning: string | null;
}>) {
if (warning === null) {
return null;
}

return (
<p
className={clsx(
"mt-1 text-amber-700 text-sm/6 sm:text-xs/6 dark:text-amber-400",
className
)}
>
{warning}
</p>
);
}
5 changes: 5 additions & 0 deletions src/components/manage/manage-edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import { Field, Fieldset, Label } from "@/components/ui/fieldset";
import { Strong, Text } from "@/components/ui/text";
import { formatRepositoryCount } from "@/format";
import type { GitHubRepository, ManageRepositoryActions } from "@/github/types";
import { ChangeWarning } from "./change-warning";
import {
MANAGE_ARCHIVE_ACTION_OPTIONS,
MANAGE_SUBSCRIPTION_ACTION_OPTIONS,
} from "./types";
import {
getArchivedVisibilityWarning,
getManageActionsSummary,
getManageVisibilityActionOptions,
getRepositoryChangeLines,
Expand Down Expand Up @@ -160,6 +162,9 @@ function RepositoryChangePreview({
) : (
<Text className="mt-1">No change needed.</Text>
)}
<ChangeWarning
warning={getArchivedVisibilityWarning(repository, actions)}
/>
</RepositoryPreviewItem>
);
}
11 changes: 10 additions & 1 deletion src/components/manage/manage-review-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
import { Strong, Text } from "@/components/ui/text";
import { formatRepositoryCount } from "@/format";
import type { GitHubRepository, ManageRepositoryActions } from "@/github/types";
import { getRepositoryChangeLines, type StagedChanges } from "./utils";
import { ChangeWarning } from "./change-warning";
import {
getArchivedVisibilityWarning,
getRepositoryChangeLines,
type StagedChanges,
} from "./utils";

/** Lists every staged change so the whole batch is confirmed at once. */
export function ManageReviewDialog({
Expand Down Expand Up @@ -105,6 +110,9 @@ function StagedChangeItem({
const changeLines = actions
? getRepositoryChangeLines(repository, actions)
: [];
const warning = actions
? getArchivedVisibilityWarning(repository, actions)
: null;

return (
<li className="flex items-start justify-between gap-3 px-3 py-2">
Expand All @@ -115,6 +123,7 @@ function StagedChangeItem({
{changeLine}
</Text>
))}
<ChangeWarning warning={warning} />
</div>
<Button
aria-label={`Remove changes for ${repository.name}`}
Expand Down
52 changes: 48 additions & 4 deletions src/components/manage/use-manage-flow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getFailedRepositoryNames } from "@/components/repositories/list-utils";
import {
type RepositoryListState,
Expand All @@ -15,16 +15,21 @@ import type {
} from "@/server-functions";
import { DEFAULT_MANAGE_ACTIONS } from "./types";
import {
type AppliedRepositoryStates,
applyRepositoryStates,
createManageChangeInput,
getAppliedRepositoryState,
hasChangedSetting,
hasManageAction,
pruneAppliedStates,
type StagedChanges,
showManageResultToast,
stageChanges,
} from "./utils";

const MANAGE_CHUNK_SIZE = 10;
const EMPTY_STAGED_CHANGES: StagedChanges = new Map();
const EMPTY_APPLIED_STATES: AppliedRepositoryStates = new Map();

const chunk = <Item>(items: Item[], size: number): Item[][] => {
const chunks: Item[][] = [];
Expand Down Expand Up @@ -82,7 +87,7 @@ export function useManageFlow({
expectedRepositoryCount,
onManageChunk,
onRunComplete,
repositories,
repositories: loadedRepositories,
}: Readonly<{
expectedRepositoryCount: number;
onManageChunk: (
Expand All @@ -91,6 +96,20 @@ export function useManageFlow({
onRunComplete: (didChangeAnything: boolean) => Promise<void>;
repositories: GitHubRepository[];
}>): ManageFlow {
// Settings a run changed show at once; the overlay lifts as the refetched list catches up.
const [appliedStates, setAppliedStates] =
useState<AppliedRepositoryStates>(EMPTY_APPLIED_STATES);
const repositories = useMemo(
() => applyRepositoryStates(loadedRepositories, appliedStates),
[appliedStates, loadedRepositories]
);

useEffect(() => {
setAppliedStates((previous) =>
pruneAppliedStates(loadedRepositories, previous)
);
}, [loadedRepositories]);

const list = useRepositoryList(repositories, {
expectedCount: expectedRepositoryCount,
});
Expand Down Expand Up @@ -211,8 +230,32 @@ export function useManageFlow({
openReview();
};

const recordAppliedStates = (
chunkResults: ManageRepositoryResult[],
changes: StagedChanges
): void => {
setAppliedStates((previous) => {
const next = new Map(previous);

for (const result of chunkResults) {
const actions = changes.get(result.repository);
const applied = actions && getAppliedRepositoryState(actions, result);

if (applied) {
next.set(result.repository, {
...previous.get(result.repository),
...applied,
});
}
}

return next;
});
};

const runChunks = async (
changeInputs: ManageRepositoryChangeInput[]
changeInputs: ManageRepositoryChangeInput[],
changes: StagedChanges
): Promise<ChunkedRun> => {
const results: ManageRepositoryResult[] = [];

Expand All @@ -232,6 +275,7 @@ export function useManageFlow({
);

results.push(...chunkResult.results);
recordAppliedStates(chunkResult.results, changes);
setManageResults([...results]);
setPendingRepositories((previousPending) =>
previousPending.filter((name) => !chunkRepositories.has(name))
Expand Down Expand Up @@ -263,7 +307,7 @@ export function useManageFlow({
let run: ChunkedRun;

try {
run = await runChunks(changeInputs);
run = await runChunks(changeInputs, stagedChanges);
} finally {
setPendingRepositories([]);
setIsManaging(false);
Expand Down
87 changes: 87 additions & 0 deletions src/components/manage/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import type {
RepositoryVisibility,
} from "@/github/types";
import {
applyRepositoryStates,
getAppliedRepositoryState,
getArchivedVisibilityWarning,
getManageActionsSummary,
getManageRepositoryStatus,
getManageResultCounts,
Expand All @@ -26,6 +29,7 @@ import {
getRepositoryChangeLines,
getRepositoryStateActions,
mergeStagedActions,
pruneAppliedStates,
showManageResultToast,
stageChanges,
} from "./utils";
Expand Down Expand Up @@ -222,6 +226,89 @@ describe("getManageActionsSummary", () => {
});
});

describe("getArchivedVisibilityWarning", () => {
it("warns when an archived repository changes visibility and stays archived", () => {
expect(
getArchivedVisibilityWarning(
createRepository({ archived: true, visibility: "public" }),
createActions({ visibilityAction: "private" })
)
).toContain("archive date");
});

it("stays quiet when the repository is unarchived, active, or keeps its visibility", () => {
const archivedPublic = createRepository({
archived: true,
visibility: "public",
});

expect(
getArchivedVisibilityWarning(
archivedPublic,
createActions({
archiveAction: "unarchived",
visibilityAction: "private",
})
)
).toBeNull();
expect(
getArchivedVisibilityWarning(
createRepository({ archived: false, visibility: "public" }),
createActions({ visibilityAction: "private" })
)
).toBeNull();
expect(
getArchivedVisibilityWarning(
archivedPublic,
createActions({ visibilityAction: "public" })
)
).toBeNull();
});
});

describe("applied repository states", () => {
it("collects only the settings a run actually changed", () => {
expect(
getAppliedRepositoryState(
createActions({
archiveAction: "archived",
subscriptionAction: "ignoring",
visibilityAction: "private",
}),
createManageResult({
archive: createSettingResult({ outcome: "changed" }),
subscription: createSettingResult({ outcome: "failed" }),
visibility: createSettingResult({ outcome: "unchanged" }),
})
)
).toEqual({ archived: true });
expect(
getAppliedRepositoryState(
createActions({ visibilityAction: "private" }),
createManageResult({
visibility: createSettingResult({ outcome: "failed" }),
})
)
).toBeNull();
});

it("overlays applied states and prunes them once the list catches up", () => {
const stale = createRepository({ name: "repo", visibility: "public" });
const other = createRepository({ name: "other" });
const applied = new Map([["repo", { visibility: "private" as const }]]);
const overlaid = applyRepositoryStates([stale, other], applied);

expect(overlaid[0]?.visibility).toBe("private");
expect(overlaid[1]).toBe(other);
expect(pruneAppliedStates([stale], applied)).toBe(applied);

const fresh = createRepository({ name: "repo", visibility: "private" });

expect(applyRepositoryStates([fresh], applied)[0]).toBe(fresh);
expect(pruneAppliedStates([fresh], applied).size).toBe(0);
});
});

describe("getRepositoryChangeLines", () => {
it("returns no lines when every action keeps the current value", () => {
expect(
Expand Down
Loading