|
1 | 1 | import { type ActionFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime"; |
2 | 2 | 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"; |
4 | 6 | import { Cog6ToothIcon, TrashIcon } from "@heroicons/react/20/solid"; |
5 | 7 | import { prisma } from "~/db.server"; |
6 | 8 | import { requireUserId } from "~/services/session.server"; |
@@ -263,17 +265,22 @@ function AutoArchiveForm({ |
263 | 265 | const valid = PreviewAutoArchivePolicy.safeParse(JSON.parse(policyKey)).success; |
264 | 266 | const reviewed = |
265 | 267 | 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 : []; |
269 | 275 | const busy = fetcher.state !== "idle"; |
270 | 276 | useEffect(() => { |
271 | 277 | if (!valid || busy) return; |
272 | 278 | const controller = new AbortController(); |
273 | 279 | const timer = setTimeout(async () => { |
274 | 280 | const policy = JSON.parse(policyKey) as { days: number; excludedBranches: string[] }; |
275 | 281 | setRequestedKey(policyKey); |
276 | | - setPreview({ pending: true }); |
| 282 | + // Keep the previous results and their height while refreshing the preview. |
| 283 | + setPreview((previous) => ({ ...previous, pending: true })); |
277 | 284 | try { |
278 | 285 | // A read-only POST avoids URL-size limits for exclusions and Remix's |
279 | 286 | // page-wide action revalidation while the user edits the form. |
@@ -351,114 +358,122 @@ function AutoArchiveForm({ |
351 | 358 | Keep specific branches |
352 | 359 | </Label> |
353 | 360 | </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> |
378 | 397 | </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> |
433 | 398 | )} |
434 | 399 | </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> |
459 | 474 | )} |
460 | | - </section> |
461 | | - )} |
| 475 | + </div> |
| 476 | + </AnimatedHeight> |
462 | 477 | </Fieldset> |
463 | 478 | {fetcher.data && !fetcher.data.ok && <FormError>{fetcher.data.error}</FormError>} |
464 | 479 | <DialogFooter> |
@@ -489,3 +504,21 @@ function AutoArchiveForm({ |
489 | 504 | </fetcher.Form> |
490 | 505 | ); |
491 | 506 | } |
| 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