Skip to content

Commit 1b4b419

Browse files
committed
fix(webapp): animate auto-archive preview and branch fields
1 parent 53e6d94 commit 1b4b419

1 file changed

Lines changed: 142 additions & 109 deletions

File tree

‎apps/webapp/app/routes/resources.branches.auto-archive.tsx‎

Lines changed: 142 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { type ActionFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
22
import { useFetcher } from "@remix-run/react";
3-
import { useEffect, useRef, useState } from "react";
3+
import { type ReactNode, useEffect, useRef, useState } from "react";
4+
import { motion, useReducedMotion } from "framer-motion";
5+
import { useMeasure } from "react-use";
46
import { Cog6ToothIcon, TrashIcon } from "@heroicons/react/20/solid";
57
import { prisma } from "~/db.server";
68
import { requireUserId } from "~/services/session.server";
@@ -263,17 +265,22 @@ function AutoArchiveForm({
263265
const valid = PreviewAutoArchivePolicy.safeParse(JSON.parse(policyKey)).success;
264266
const reviewed =
265267
valid && !preview.pending && preview.data?.ok && preview.data.policyKey === policyKey;
266-
const checkingProtectedBranches =
267-
valid && !reviewed && excluded.trim() !== "" && (preview.pending || requestedKey !== policyKey);
268-
const protectedBranches = reviewed && preview.data?.ok ? preview.data.protectedBranches : [];
268+
const previewError =
269+
requestedKey === policyKey && !preview.pending && preview.data && !preview.data.ok
270+
? preview.data.error
271+
: undefined;
272+
const checkingPreview = valid && !reviewed && !previewError;
273+
const protectedBranches =
274+
valid && !previewError && preview.data?.ok ? preview.data.protectedBranches : [];
269275
const busy = fetcher.state !== "idle";
270276
useEffect(() => {
271277
if (!valid || busy) return;
272278
const controller = new AbortController();
273279
const timer = setTimeout(async () => {
274280
const policy = JSON.parse(policyKey) as { days: number; excludedBranches: string[] };
275281
setRequestedKey(policyKey);
276-
setPreview({ pending: true });
282+
// Keep the previous results and their height while refreshing the preview.
283+
setPreview((previous) => ({ ...previous, pending: true }));
277284
try {
278285
// A read-only POST avoids URL-size limits for exclusions and Remix's
279286
// page-wide action revalidation while the user edits the form.
@@ -351,114 +358,122 @@ function AutoArchiveForm({
351358
Keep specific branches
352359
</Label>
353360
</div>
354-
{keepSpecificBranches && (
355-
<div id="archive-exclusions" className="grid gap-1.5 pl-6">
356-
{excludedRows.map((row, index) => (
357-
<div key={row.id} className="flex items-center gap-2">
358-
<Input
359-
id={`archive-exclusion-${row.id}`}
360-
aria-label={`Branch to keep ${index + 1}`}
361-
aria-describedby="archive-exclusions-description"
362-
maxLength={255}
363-
placeholder={index === 0 ? "Branch name, e.g. staging" : "Add another branch"}
364-
disabled={busy}
365-
value={row.name}
366-
onChange={(event) => updateExcludedBranch(row.id, event.target.value)}
367-
/>
368-
{(row.name !== "" || index < excludedRows.length - 1) && (
369-
<Button
370-
type="button"
371-
variant="secondary/medium"
372-
LeadingIcon={TrashIcon}
373-
aria-label={`Remove branch ${row.name || index + 1}`}
374-
disabled={busy}
375-
onClick={() => removeExcludedBranch(row.id)}
376-
/>
377-
)}
361+
<AnimatedHeight>
362+
<div id="archive-exclusions">
363+
{keepSpecificBranches && (
364+
<div className="grid gap-1.5 pl-6">
365+
{excludedRows.map((row, index) => (
366+
<div key={row.id} className="flex items-center gap-2">
367+
<Input
368+
id={`archive-exclusion-${row.id}`}
369+
aria-label={`Branch to keep ${index + 1}`}
370+
aria-describedby="archive-exclusions-description"
371+
maxLength={255}
372+
placeholder={
373+
index === 0 ? "Branch name, e.g. staging" : "Add another branch"
374+
}
375+
disabled={busy}
376+
value={row.name}
377+
onChange={(event) => updateExcludedBranch(row.id, event.target.value)}
378+
/>
379+
{(row.name !== "" || index < excludedRows.length - 1) && (
380+
<Button
381+
type="button"
382+
variant="secondary/medium"
383+
LeadingIcon={TrashIcon}
384+
aria-label={`Remove branch ${row.name || index + 1}`}
385+
disabled={busy}
386+
onClick={() => removeExcludedBranch(row.id)}
387+
/>
388+
)}
389+
</div>
390+
))}
391+
<div id="archive-exclusions-description">
392+
<Hint>
393+
Never auto-archive these branches. Enter one exact branch name per field; no
394+
wildcards.
395+
</Hint>
396+
</div>
378397
</div>
379-
))}
380-
<div id="archive-exclusions-description">
381-
<Hint>
382-
Never auto-archive these branches. Enter one exact branch name per field; no
383-
wildcards.
384-
</Hint>
385-
</div>
386-
</div>
387-
)}
388-
</InputGroup>
389-
<section
390-
className="max-h-48 overflow-y-auto"
391-
aria-labelledby="archive-preview-title"
392-
aria-live="polite"
393-
aria-busy={valid && !reviewed && preview.pending}
394-
>
395-
<h3 id="archive-preview-title" className={`${labelVariants.medium.text} mb-2`}>
396-
Preview
397-
</h3>
398-
{!valid ? (
399-
<Paragraph>Enter 1–365 days and valid branch names to see the preview.</Paragraph>
400-
) : reviewed && preview.data?.ok ? (
401-
<div className="space-y-2 text-sm tabular-nums text-text-dimmed">
402-
<ul className="list-disc space-y-1 pl-4">
403-
<li className={preview.data.count > 0 ? "text-amber-400" : undefined}>
404-
{preview.data.partial ? "At least " : ""}
405-
{preview.data.count} {preview.data.count === 1 ? "branch is" : "branches are"}{" "}
406-
ready to archive now.
407-
{preview.data.count > 0 &&
408-
" Saving will make these branches eligible for the next cleanup check."}
409-
</li>
410-
<li>
411-
{preview.data.partial ? "At least " : ""}
412-
{preview.data.scheduled}{" "}
413-
{preview.data.scheduled === 1 ? "branch will" : "branches will"} archive later if
414-
no new deployments occur.
415-
</li>
416-
<li>
417-
{preview.data.partial ? "At least " : ""}
418-
{preview.data.protectedBranches.length} protected{" "}
419-
{preview.data.protectedBranches.length === 1 ? "branch will" : "branches will"} be
420-
ignored.
421-
</li>
422-
{preview.data.inProgress > 0 && (
423-
<li>
424-
{preview.data.partial ? "At least " : ""}
425-
{preview.data.inProgress}{" "}
426-
{preview.data.inProgress === 1 ? "branch has" : "branches have"} a deployment in
427-
progress and will be skipped.
428-
</li>
429-
)}
430-
</ul>
431-
{preview.data.partial && (
432-
<p>Preview limited to the first 1,000 branches. Cleanup checks all branches.</p>
433398
)}
434399
</div>
435-
) : requestedKey === policyKey && !preview.pending && preview.data && !preview.data.ok ? (
436-
<FormError>{preview.data.error}</FormError>
437-
) : (
438-
<Paragraph>Checking branches…</Paragraph>
439-
)}
440-
</section>
441-
{(checkingProtectedBranches || protectedBranches.length > 0) && (
442-
<section
443-
className="max-h-48 overflow-y-auto"
444-
aria-labelledby="protected-branches-title"
445-
aria-busy={checkingProtectedBranches}
446-
>
447-
<h3 id="protected-branches-title" className={`${labelVariants.medium.text} mb-2`}>
448-
Protected branches
449-
{checkingProtectedBranches && <Spinner color="blue" className="ml-1 size-3" />}
450-
</h3>
451-
{!checkingProtectedBranches && (
452-
<ul className="list-disc space-y-1 pl-4 text-sm text-text-dimmed">
453-
{protectedBranches.map((branch) => (
454-
<li key={branch} className="break-words">
455-
{branch}
456-
</li>
457-
))}
458-
</ul>
400+
</AnimatedHeight>
401+
</InputGroup>
402+
<AnimatedHeight>
403+
<div className="flex flex-col gap-5">
404+
<section
405+
className="max-h-48 overflow-y-auto"
406+
aria-labelledby="archive-preview-title"
407+
aria-live="polite"
408+
aria-busy={checkingPreview}
409+
>
410+
<h3 id="archive-preview-title" className={`${labelVariants.medium.text} mb-2`}>
411+
Preview
412+
{checkingPreview && <Spinner color="blue" className="ml-1 size-3" />}
413+
</h3>
414+
{!valid ? (
415+
<Paragraph>Enter 1–365 days and valid branch names to see the preview.</Paragraph>
416+
) : previewError ? (
417+
<FormError>{previewError}</FormError>
418+
) : preview.data?.ok ? (
419+
<div className="space-y-2 text-sm tabular-nums text-text-dimmed">
420+
<ul className="list-disc space-y-1 pl-4">
421+
<li className={preview.data.count > 0 ? "text-amber-400" : undefined}>
422+
{preview.data.partial ? "At least " : ""}
423+
{preview.data.count} {preview.data.count === 1 ? "branch is" : "branches are"}{" "}
424+
ready to archive now.
425+
{preview.data.count > 0 &&
426+
" Saving will make these branches eligible for the next cleanup check."}
427+
</li>
428+
<li>
429+
{preview.data.partial ? "At least " : ""}
430+
{preview.data.scheduled}{" "}
431+
{preview.data.scheduled === 1 ? "branch will" : "branches will"} archive later
432+
if no new deployments occur.
433+
</li>
434+
<li>
435+
{preview.data.partial ? "At least " : ""}
436+
{preview.data.protectedBranches.length} protected{" "}
437+
{preview.data.protectedBranches.length === 1
438+
? "branch will"
439+
: "branches will"}{" "}
440+
be ignored.
441+
</li>
442+
{preview.data.inProgress > 0 && (
443+
<li>
444+
{preview.data.partial ? "At least " : ""}
445+
{preview.data.inProgress}{" "}
446+
{preview.data.inProgress === 1 ? "branch has" : "branches have"} a
447+
deployment in progress and will be skipped.
448+
</li>
449+
)}
450+
</ul>
451+
{preview.data.partial && (
452+
<p>Preview limited to the first 1,000 branches. Cleanup checks all branches.</p>
453+
)}
454+
</div>
455+
) : null}
456+
</section>
457+
{protectedBranches.length > 0 && (
458+
<section
459+
className="max-h-48 overflow-y-auto"
460+
aria-labelledby="protected-branches-title"
461+
aria-busy={checkingPreview}
462+
>
463+
<h3 id="protected-branches-title" className={`${labelVariants.medium.text} mb-2`}>
464+
Protected branches
465+
</h3>
466+
<ul className="list-disc space-y-1 pl-4 text-sm text-text-dimmed">
467+
{protectedBranches.map((branch) => (
468+
<li key={branch} className="break-words">
469+
{branch}
470+
</li>
471+
))}
472+
</ul>
473+
</section>
459474
)}
460-
</section>
461-
)}
475+
</div>
476+
</AnimatedHeight>
462477
</Fieldset>
463478
{fetcher.data && !fetcher.data.ok && <FormError>{fetcher.data.error}</FormError>}
464479
<DialogFooter>
@@ -489,3 +504,21 @@ function AutoArchiveForm({
489504
</fetcher.Form>
490505
);
491506
}
507+
508+
function AnimatedHeight({ children }: { children: ReactNode }) {
509+
const [contentRef, { height, width }] = useMeasure<HTMLDivElement>();
510+
const reduceMotion = useReducedMotion();
511+
512+
return (
513+
<motion.div
514+
initial={false}
515+
animate={{ height: width > 0 ? height : "auto" }}
516+
transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeInOut" }}
517+
className="overflow-hidden"
518+
>
519+
<div ref={contentRef} className="flow-root">
520+
{children}
521+
</div>
522+
</motion.div>
523+
);
524+
}

0 commit comments

Comments
 (0)