working on restore form.

This commit is contained in:
killian-larcher
2024-11-17 19:13:51 +01:00
parent ff9e3ffa67
commit 5d19970373
19 changed files with 1381 additions and 32 deletions
+2
View File
@@ -37,3 +37,5 @@ next-env.d.ts
public/uploads/*
!public/uploads/
/.env
+2
View File
@@ -1,3 +1,5 @@
import React from "react";
import {LayoutAdmin} from "@/components/layout";
import {currentUser} from "@/auth/current-user";
import {redirect} from "next/navigation";
@@ -1,5 +1,5 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentForm} from "@/components/wrappers/Agent/AgentForm/AgentForm";
import {requiredCurrentUser} from "@/auth/current-user";
import {prisma} from "@/prisma";
@@ -9,14 +9,17 @@ import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{
agentId: string;
}>) {
const {agentId} = await props.params
const user = await requiredCurrentUser()
const agent = await prisma.agent.findUnique({
where: {
id: props.params.agentId,
id: agentId,
}
});
if (!agent){
if (!agent) {
notFound();
}
@@ -13,9 +13,11 @@ import Link from "next/link";
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
const {agentId} = await props.params
const agent = await prisma.agent.findUnique({
where: {
id: props.params.agentId,
id: agentId,
},
})
@@ -112,7 +114,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
Last contact
</CardHeader>
<CardContent>
{agent.lastContact?.toDateString() ?? "Never connected"}
{agent.lastContact?.toDateString() ?? "Never connected."}
</CardContent>
</Card>
</div>
@@ -4,6 +4,7 @@ import {AgentForm} from "@/components/wrappers/Agent/AgentForm/AgentForm";
export default async function RoutePage(props: PageParams<{}>) {
return (
<Page>
<PageHeader>
@@ -0,0 +1,55 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {requiredCurrentUser} from "@/auth/current-user";
import {prisma} from "@/prisma";
import {notFound} from "next/navigation";
import {RestoreForm} from "@/components/wrappers/Database/RestoreForm";
export default async function RoutePage(props: PageParams<{
databaseId: string;
}>) {
const {databaseId} = await props.params
const user = await requiredCurrentUser()
const database = await prisma.database.findUnique({
where: {
id: databaseId,
}
});
const databases = await prisma.database.findMany({
where: {
dbms: database.dbms,
}
})
const backups = await prisma.backup.findMany({
where: {
status: "success",
}
});
if (!database) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>
Restore {database.name}
</PageTitle>
</PageHeader>
<PageContent>
<RestoreForm databaseToRestore={database} databases={databases} backups={backups}/>
</PageContent>
</Page>
)
}
+2 -2
View File
@@ -48,7 +48,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.3.1",
"input-otp": "^1.4.0",
"lucide-react": "^0.454.0",
@@ -59,7 +59,7 @@
"next-safe-action": "^7.4.3",
"next-themes": "^0.3.0",
"react": "19.0.0-rc-66855b96-20241106",
"react-day-picker": "^8.10.1",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-hook-form": "^7.53.1",
"react-resizable-panels": "^2.1.6",
@@ -0,0 +1,118 @@
/*
Warnings:
- You are about to drop the `Agent` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Backup` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Database` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Restauration` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Settings` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Backup" DROP CONSTRAINT "Backup_database_id_fkey";
-- DropForeignKey
ALTER TABLE "Database" DROP CONSTRAINT "Database_agent_id_fkey";
-- DropForeignKey
ALTER TABLE "Restauration" DROP CONSTRAINT "Restauration_backup_id_fkey";
-- DropForeignKey
ALTER TABLE "Restauration" DROP CONSTRAINT "Restauration_database_id_fkey";
-- DropTable
DROP TABLE "Agent";
-- DropTable
DROP TABLE "Backup";
-- DropTable
DROP TABLE "Database";
-- DropTable
DROP TABLE "Restauration";
-- DropTable
DROP TABLE "Settings";
-- CreateTable
CREATE TABLE "agents" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"last_contact" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "databases" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"backup_policy" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"agent_id" TEXT NOT NULL,
CONSTRAINT "databases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "backups" (
"id" TEXT NOT NULL,
"status" "Status" NOT NULL DEFAULT 'waiting',
"file" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"database_id" TEXT NOT NULL,
CONSTRAINT "backups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "restaurations" (
"id" TEXT NOT NULL,
"status" "Status" NOT NULL DEFAULT 'waiting',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"backup_id" TEXT NOT NULL,
"database_id" TEXT,
CONSTRAINT "restaurations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settings" (
"id" TEXT NOT NULL,
"storage" "TypeStorage" NOT NULL DEFAULT 'local',
"name" TEXT NOT NULL,
"s3EndPointUrl" TEXT,
"s3AccessKeyId" TEXT,
"s3SecretAccessKey" TEXT,
"S3BucketName" TEXT,
"smtpPassword" TEXT,
"smtpFrom" TEXT,
"smtpHost" TEXT,
"smtpPort" TEXT,
"smtpUser" TEXT,
CONSTRAINT "settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "agents_slug_key" ON "agents"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "settings_name_key" ON "settings"("name");
-- AddForeignKey
ALTER TABLE "databases" ADD CONSTRAINT "databases_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "backups" ADD CONSTRAINT "backups_database_id_fkey" FOREIGN KEY ("database_id") REFERENCES "databases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "restaurations" ADD CONSTRAINT "restaurations_backup_id_fkey" FOREIGN KEY ("backup_id") REFERENCES "backups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "restaurations" ADD CONSTRAINT "restaurations_database_id_fkey" FOREIGN KEY ("database_id") REFERENCES "databases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -80,11 +80,20 @@ model Agent {
createdAt DateTime @default(now()) @map("created_at")
databases Database[]
@@map("agents")
}
enum Dbms {
postgresql
mysql
mongodb
}
model Database {
id String @id @default(cuid())
name String
dbms Dbms
description String?
backupPolicy String? @map("backup_policy")
createdAt DateTime @default(now()) @map("created_at")
@@ -94,6 +103,8 @@ model Database {
backups Backup[]
restaurations Restauration[]
@@map("databases")
}
enum Status {
@@ -113,6 +124,8 @@ model Backup {
database Database @relation(fields: [databaseId], references: [id], onDelete: Cascade)
restaurations Restauration[]
@@map("backups")
}
model Restauration {
@@ -125,6 +138,8 @@ model Restauration {
databaseId String? @map("database_id")
database Database? @relation(fields: [databaseId], references: [id], onDelete: Cascade)
@@map("restaurations")
}
enum TypeStorage {
@@ -145,4 +160,6 @@ model Settings {
smtpHost String?
smtpPort String?
smtpUser String?
@@map("settings")
}
+1 -1
View File
@@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
+2 -5
View File
@@ -2,16 +2,13 @@ import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+4 -10
View File
@@ -1,15 +1,9 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
const Select = SelectPrimitive.Root
@@ -31,7 +25,7 @@ const SelectTrigger = React.forwardRef<
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
@@ -49,7 +43,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
<ChevronUpIcon />
<ChevronUpIcon className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
@@ -66,7 +60,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDownIcon />
<ChevronDownIcon className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
@@ -20,7 +20,7 @@ export const AgentCard = (props: agentCardProps) => {
<div className="">
<CardHeader>{agent.name}</CardHeader>
<CardContent>
Last contact : {agent.lastContact?.toDateString() ?? "Never connected"}
Last contact : {agent.lastContact?.toDateString() ?? "Never connected."}
</CardContent>
</div>
<div className="mt-3 mr-3">
@@ -0,0 +1,207 @@
"use client";
import {useState} from "react";
import {DateTimePicker} from "@/components/wrappers/daytime-picker";
import {Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {RestoreSchema} from "@/components/wrappers/Database/restore-form.schema";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
import {ComboBox, ComboBoxFormItem} from "@/components/wrappers/combobox";
import {Label} from "@/components/ui/label";
export type restoreFormProps = {
databaseToRestore: any
databases: any[]
backups: any[]
}
export const RestoreForm = (props: restoreFormProps) => {
const {databaseToRestore, databases, backups} = props
console.log("bacups", backups)
console.log("bacups", databaseToRestore)
const backupLocations = [
{
value: "remote-file",
label: "Remote File",
},
{
value: "desktop-file",
label: "Desktop File",
},
]
const executionModes = [
{
value: "immediate",
label: "Immediate",
},
{
value: "scheduled",
label: "Scheduled",
},
]
const [selectedDatabaseId, setSelectedDatabaseId] = useState(databaseToRestore.id)
const filteredBackups = backups.filter(backup => backup.databaseId == selectedDatabaseId)
const defaultValues = {
executionMode: "immediate",
backupLocation: "remote-file",
}
const form = useZodForm({
schema: RestoreSchema,
defaultValues: defaultValues
});
const values = form.getValues()
console.log("values", values)
const mutation = useMutation({})
return (
<div>
<Form
form={form}
onSubmit={async (values) => {
console.log(values);
}}
>
<FormField
control={form.control}
name="backupLocation"
render={({field}) => (
<FormItem>
<FormLabel>Backup location</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue/>
</SelectTrigger>
</FormControl>
<SelectContent>
{backupLocations.map((backupLocation, key) =>
<SelectItem key={key} value={backupLocation.value}>
{backupLocation.label}
</SelectItem>
)}
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
{values.backupLocation == "remote-file" ?
<>
<div className="flex flex-col">
<Label>Database</Label>
<ComboBox
values={databases.map(database =>
({"value": database.id, "label": database.name})
)}
onValueChange={setSelectedDatabaseId}
defaultValue={selectedDatabaseId}
searchField
/>
</div>
<FormField
control={form.control}
name="remoteBackup"
render={({field}) => (
<FormItem className="flex flex-col">
<FormLabel>Remote backup</FormLabel>
<ComboBoxFormItem
values={filteredBackups.map(backup => ({
"value": backup.id,
"label": backup.createdAt.toString()
})
)}
{...field}
/>
<FormMessage/>
</FormItem>
)}
/>
</>
: null}
{values.backupLocation == "desktop-file" ?
<FormField
control={form.control}
name="uploadedBackupFile"
render={({field}) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Input id="uploadedBackupFile" type="file" {...field}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/> : null}
<FormField
control={form.control}
name="executionMode"
render={({field}) => (
<FormItem>
<FormLabel>Execution mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue/>
</SelectTrigger>
</FormControl>
<SelectContent>
{executionModes.map((executionMode, key) =>
<SelectItem key={key} value={executionMode.value}>
{executionMode.label}
</SelectItem>
)}
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
{values.executionMode == "scheduled" ?
<FormField
control={form.control}
name="scheduledDatetime"
render={({field}) => (
<FormItem>
<FormLabel>Scheduled date</FormLabel>
<FormControl>
<DateTimePicker value={field.value} onChange={field.onChange}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/> : null}
<Button type="submit">Launch restore</Button>
</Form>
</div>
)
}
@@ -0,0 +1,24 @@
import {z} from "zod";
const ImmediateExecutionSchema = z.object({
executionMode: z.literal('immediate'),
});
const ScheduledExecutionSchema = z.object({
executionMode: z.literal('scheduled'),
scheduledDatetime: z.date(),
});
const CommonBackupSchema = z.union([ImmediateExecutionSchema, ScheduledExecutionSchema]);
const RemoteBackupSchema = z.object({
backupLocation: z.literal('remote-file'),
}).merge(CommonBackupSchema);
const DesktopBackupSchema = z.object({
backupLocation: z.literal('desktop-file'),
uploadedBackupFile: z.instanceof(File),
}).merge(CommonBackupSchema);
export const RestoreSchema = z.union([RemoteBackupSchema, DesktopBackupSchema]);
export type RestoreType = z.infer<typeof RestoreSchema>;
+156
View File
@@ -0,0 +1,156 @@
"use client"
import {useEffect, useState} from "react";
import {Check, ChevronsUpDown} from "lucide-react"
import {cn} from "@/lib/utils"
import {Button} from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {FormControl} from "@/components/ui/form";
export type comboBoxProps = {
values: Array<{ value: string, label: string }>
defaultValue?: string
onValueChange?: any
searchField?: boolean
}
export function ComboBox(props: comboBoxProps) {
const {values: choices, defaultValue: defaultChoice = "", onValueChange, searchField = false} = props;
const [value, setValue] = useState(defaultChoice)
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? choices.find((choice) => choice.value === value)?.label
: "Select choice..."}
<ChevronsUpDown className="opacity-50"/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
<CommandList>
<CommandEmpty>No choice found.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
key={choice.value}
value={choice.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue)
onValueChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
>
{choice.label}
<Check
className={cn(
"ml-auto",
value === choice.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export type comboBoxFormItemProps = comboBoxProps & {
value: any
name: any
onChange: any
};
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
const {values: choices, searchField = false, value, name, onChange} = props;
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-[200px] justify-between",
!value && "text-muted-foreground"
)}
>
{value
? choices.find(
(choice) => choice.value === value
)?.label
: `Select ${name}`}
<ChevronsUpDown className="opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
<CommandList>
<CommandEmpty>No {name} found.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
value={choice.label}
key={choice.value}
onSelect={() => {
onChange(choice.value)
setOpen(false)
}}
>
{choice.label}
<Check
className={cn(
"ml-auto",
choice.value === value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+770
View File
@@ -0,0 +1,770 @@
import {Button, buttonVariants} from '@/components/ui/button';
import type {CalendarProps} from '@/components/ui/calendar';
import {Input} from '@/components/ui/input';
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
import {cn} from '@/lib/utils';
import {add, format} from 'date-fns';
import {type Locale, enUS} from 'date-fns/locale';
import {Calendar as CalendarIcon, ChevronLeft, ChevronRight} from 'lucide-react';
import {Clock} from 'lucide-react';
import * as React from 'react';
import {useImperativeHandle, useRef} from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {DayPicker} from 'react-day-picker';
// ---------- utils start ----------
/**
* regular expression to check for valid hour format (01-23)
*/
function isValidHour(value: string) {
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
}
/**
* regular expression to check for valid 12 hour format (01-12)
*/
function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
function getValidNumber(value: string, {max, min = 0, loop = false}: GetValidNumberConfig) {
let numericValue = parseInt(value, 10);
if (!Number.isNaN(numericValue)) {
if (!loop) {
if (numericValue > max) numericValue = max;
if (numericValue < min) numericValue = min;
} else {
if (numericValue > max) numericValue = min;
if (numericValue < min) numericValue = max;
}
return numericValue.toString().padStart(2, '0');
}
return '00';
}
function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, {max: 23});
}
function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, {min: 1, max: 12});
}
function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, {max: 59});
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
function getValidArrowNumber(value: string, {min, max, step}: GetValidArrowNumberConfig) {
let numericValue = parseInt(value, 10);
if (!Number.isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), {min, max, loop: true});
}
return '00';
}
function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, {min: 0, max: 23, step});
}
function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, {min: 1, max: 12, step});
}
function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, {min: 0, max: 59, step});
}
function setMinutes(date: Date, value: string) {
const minutes = getValidMinuteOrSecond(value);
date.setMinutes(parseInt(minutes, 10));
return date;
}
function setSeconds(date: Date, value: string) {
const seconds = getValidMinuteOrSecond(value);
date.setSeconds(parseInt(seconds, 10));
return date;
}
function setHours(date: Date, value: string) {
const hours = getValidHour(value);
date.setHours(parseInt(hours, 10));
return date;
}
function set12Hours(date: Date, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
date.setHours(convertedHours);
return date;
}
type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
type Period = 'AM' | 'PM';
function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
switch (type) {
case 'minutes':
return setMinutes(date, value);
case 'seconds':
return setSeconds(date, value);
case 'hours':
return setHours(date, value);
case '12hours': {
if (!period) return date;
return set12Hours(date, value, period);
}
default:
return date;
}
}
function getDateByType(date: Date | null, type: TimePickerType) {
if (!date) return '00';
switch (type) {
case 'minutes':
return getValidMinuteOrSecond(String(date.getMinutes()));
case 'seconds':
return getValidMinuteOrSecond(String(date.getSeconds()));
case 'hours':
return getValidHour(String(date.getHours()));
case '12hours':
return getValid12Hour(String(display12HourValue(date.getHours())));
default:
return '00';
}
}
function getArrowByType(value: string, step: number, type: TimePickerType) {
switch (type) {
case 'minutes':
return getValidArrowMinuteOrSecond(value, step);
case 'seconds':
return getValidArrowMinuteOrSecond(value, step);
case 'hours':
return getValidArrowHour(value, step);
case '12hours':
return getValidArrow12Hour(value, step);
default:
return '00';
}
}
/**
* handles value change of 12-hour input
* 12:00 PM is 12:00
* 12:00 AM is 00:00
*/
function convert12HourTo24Hour(hour: number, period: Period) {
if (period === 'PM') {
if (hour <= 11) {
return hour + 12;
}
return hour;
}
if (period === 'AM') {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
function display12HourValue(hours: number) {
if (hours === 0 || hours === 12) return '12';
if (hours >= 22) return `${hours - 12}`;
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}
function genMonths(locale: Pick<Locale, 'options' | 'localize' | 'formatLong'>) {
return Array.from({length: 12}, (_, i) => ({
value: i,
label: format(new Date(2021, i), 'MMMM', {locale}),
}));
}
function genYears(yearRange = 50) {
const today = new Date();
return Array.from({length: yearRange * 2 + 1}, (_, i) => ({
value: today.getFullYear() - yearRange + i,
label: (today.getFullYear() - yearRange + i).toString(),
}));
}
// ---------- utils end ----------
function Calendar({
className,
classNames,
showOutsideDays = true,
yearRange = 50,
...props
}: CalendarProps & { yearRange?: number }) {
const MONTHS = React.useMemo(() => {
let locale: Pick<Locale, 'options' | 'localize' | 'formatLong'> = enUS;
const {options, localize, formatLong} = props.locale || {};
if (options && localize && formatLong) {
locale = {
options,
localize,
formatLong,
};
}
return genMonths(locale);
}, []);
const YEARS = React.useMemo(() => genYears(yearRange), []);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-y-0 justify-center',
month: 'flex flex-col items-center space-y-4',
month_caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center ',
button_previous: cn(
buttonVariants({variant: 'outline'}),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-5 top-5',
),
button_next: cn(
buttonVariants({variant: 'outline'}),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-5 top-5',
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: cn('flex', props.showWeekNumber && 'justify-end'),
weekday: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 rounded-1',
day_button: cn(
buttonVariants({variant: 'ghost'}),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100 rounded-l-md rounded-r-md',
),
range_end: 'day-range-end',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-l-md rounded-r-md',
today: 'bg-accent text-accent-foreground',
outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
disabled: 'text-muted-foreground opacity-50',
range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({...props}) =>
props.orientation === 'left' ? (
<ChevronLeft className="h-4 w-4"/>
) : (
<ChevronRight className="h-4 w-4"/>
),
MonthCaption: ({calendarMonth}) => {
return (
<div className="inline-flex gap-2">
<Select
defaultValue={calendarMonth.date.getMonth().toString()}
onValueChange={(value) => {
const newDate = new Date(calendarMonth.date);
newDate.setMonth(Number.parseInt(value, 10));
props.onMonthChange?.(newDate);
}}
>
<SelectTrigger
className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
<SelectValue/>
</SelectTrigger>
<SelectContent>
{MONTHS.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
defaultValue={calendarMonth.date.getFullYear().toString()}
onValueChange={(value) => {
const newDate = new Date(calendarMonth.date);
newDate.setFullYear(Number.parseInt(value, 10));
props.onMonthChange?.(newDate);
}}
>
<SelectTrigger
className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
<SelectValue/>
</SelectTrigger>
<SelectContent>
{YEARS.map((year) => (
<SelectItem key={year.value} value={year.value.toString()}>
{year.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
},
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
interface PeriodSelectorProps {
period: Period;
setPeriod?: (m: Period) => void;
date?: Date | null;
onDateChange?: (date: Date | undefined) => void;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
const TimePeriodSelect = React.forwardRef<HTMLButtonElement, PeriodSelectorProps>(
({period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus}, ref) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
};
const handleValueChange = (value: Period) => {
setPeriod?.(value);
/**
* trigger an update whenever the user switches between AM and PM;
* otherwise user must manually change the hour each time
*/
if (date) {
const tempDate = new Date(date);
const hours = display12HourValue(date.getHours());
onDateChange?.(
setDateByType(tempDate, hours.toString(), '12hours', period === 'AM' ? 'PM' : 'AM'),
);
}
};
return (
<div className="flex h-10 items-center">
<Select defaultValue={period} onValueChange={(value: Period) => handleValueChange(value)}>
<SelectTrigger
ref={ref}
className="w-[65px] focus:bg-accent focus:text-accent-foreground"
onKeyDown={handleKeyDown}
>
<SelectValue/>
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
);
},
);
TimePeriodSelect.displayName = 'TimePeriodSelect';
interface TimePickerInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType;
date?: Date | null;
onDateChange?: (date: Date | undefined) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
const TimePickerInput = React.forwardRef<HTMLInputElement, TimePickerInputProps>(
(
{
className,
type = 'tel',
value,
id,
name,
date = new Date(new Date().setHours(0, 0, 0, 0)),
onDateChange,
onChange,
onKeyDown,
picker,
period,
onLeftFocus,
onRightFocus,
...props
},
ref,
) => {
const [flag, setFlag] = React.useState<boolean>(false);
const [prevIntKey, setPrevIntKey] = React.useState<string>('0');
/**
* allow the user to enter the second digit within 2 seconds
* otherwise start again with entering first digit
*/
React.useEffect(() => {
if (flag) {
const timer = setTimeout(() => {
setFlag(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [flag]);
const calculatedValue = React.useMemo(() => {
return getDateByType(date, picker);
}, [date, picker]);
const calculateNewValue = (key: string) => {
/*
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
* The second entered digit will break the condition and the value will be set to 10-12.
*/
if (picker === '12hours') {
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return `0${key}`;
}
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Tab') return;
e.preventDefault();
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
const step = e.key === 'ArrowUp' ? 1 : -1;
const newValue = getArrowByType(calculatedValue, step, picker);
if (flag) setFlag(false);
const tempDate = date ? new Date(date) : new Date();
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
}
if (e.key >= '0' && e.key <= '9') {
if (picker === '12hours') setPrevIntKey(e.key);
const newValue = calculateNewValue(e.key);
if (flag) onRightFocus?.();
setFlag((prev) => !prev);
const tempDate = date ? new Date(date) : new Date();
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
}
};
return (
<Input
ref={ref}
id={id || picker}
name={name || picker}
className={cn(
'w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
className,
)}
value={value || calculatedValue}
onChange={(e) => {
e.preventDefault();
onChange?.(e);
}}
type={type}
inputMode="decimal"
onKeyDown={(e) => {
onKeyDown?.(e);
handleKeyDown(e);
}}
{...props}
/>
);
},
);
TimePickerInput.displayName = 'TimePickerInput';
interface TimePickerProps {
date?: Date | null;
onChange?: (date: Date | undefined) => void;
hourCycle?: 12 | 24;
/**
* Determines the smallest unit that is displayed in the datetime picker.
* Default is 'second'.
* */
granularity?: Granularity;
}
interface TimePickerRef {
minuteRef: HTMLInputElement | null;
hourRef: HTMLInputElement | null;
secondRef: HTMLInputElement | null;
}
const TimePicker = React.forwardRef<TimePickerRef, TimePickerProps>(
({date, onChange, hourCycle = 24, granularity = 'second'}, ref) => {
const minuteRef = React.useRef<HTMLInputElement>(null);
const hourRef = React.useRef<HTMLInputElement>(null);
const secondRef = React.useRef<HTMLInputElement>(null);
const periodRef = React.useRef<HTMLButtonElement>(null);
const [period, setPeriod] = React.useState<Period>(date && date.getHours() >= 12 ? 'PM' : 'AM');
useImperativeHandle(
ref,
() => ({
minuteRef: minuteRef.current,
hourRef: hourRef.current,
secondRef: secondRef.current,
periodRef: periodRef.current,
}),
[minuteRef, hourRef, secondRef],
);
return (
<div className="flex items-center justify-center gap-2">
<label htmlFor="datetime-picker-hour-input" className="cursor-pointer">
<Clock className="mr-2 h-4 w-4"/>
</label>
<TimePickerInput
picker={hourCycle === 24 ? 'hours' : '12hours'}
date={date}
id="datetime-picker-hour-input"
onDateChange={onChange}
ref={hourRef}
period={period}
onRightFocus={() => minuteRef?.current?.focus()}
/>
{(granularity === 'minute' || granularity === 'second') && (
<>
:
<TimePickerInput
picker="minutes"
date={date}
onDateChange={onChange}
ref={minuteRef}
onLeftFocus={() => hourRef?.current?.focus()}
onRightFocus={() => secondRef?.current?.focus()}
/>
</>
)}
{granularity === 'second' && (
<>
:
<TimePickerInput
picker="seconds"
date={date}
onDateChange={onChange}
ref={secondRef}
onLeftFocus={() => minuteRef?.current?.focus()}
onRightFocus={() => periodRef?.current?.focus()}
/>
</>
)}
{hourCycle === 12 && (
<div className="grid gap-1 text-center">
<TimePeriodSelect
period={period}
setPeriod={setPeriod}
date={date}
onDateChange={(date) => {
onChange?.(date);
if (date && date?.getHours() >= 12) {
setPeriod('PM');
} else {
setPeriod('AM');
}
}}
ref={periodRef}
onLeftFocus={() => secondRef?.current?.focus()}
/>
</div>
)}
</div>
);
},
);
TimePicker.displayName = 'TimePicker';
type Granularity = 'day' | 'hour' | 'minute' | 'second';
type DateTimePickerProps = {
value?: Date;
onChange?: (date: Date | undefined) => void;
disabled?: boolean;
/** showing `AM/PM` or not. */
hourCycle?: 12 | 24;
placeholder?: string;
/**
* The year range will be: `This year + yearRange` and `this year - yearRange`.
* Default is 50.
* For example:
* This year is 2024, The year dropdown will be 1974 to 2024 which is generated by `2024 - 50 = 1974` and `2024 + 50 = 2074`.
* */
yearRange?: number;
/**
* The format is derived from the `date-fns` documentation.
* @reference https://date-fns.org/v3.6.0/docs/format
**/
displayFormat?: { hour24?: string; hour12?: string };
/**
* The granularity prop allows you to control the smallest unit that is displayed by DateTimePicker.
* By default, the value is `second` which shows all time inputs.
**/
granularity?: Granularity;
className?: string;
} & Pick<CalendarProps, 'locale' | 'weekStartsOn' | 'showWeekNumber' | 'showOutsideDays'>;
type DateTimePickerRef = {
value?: Date;
} & Omit<HTMLButtonElement, 'value'>;
const DateTimePicker = React.forwardRef<Partial<DateTimePickerRef>, DateTimePickerProps>(
(
{
locale = enUS,
value,
onChange,
hourCycle = 24,
yearRange = 50,
disabled = false,
displayFormat,
granularity = 'second',
placeholder = 'Pick a date',
className,
...props
},
ref,
) => {
const [month, setMonth] = React.useState<Date>(value ?? new Date());
const buttonRef = useRef<HTMLButtonElement>(null);
/**
* carry over the current time when a user clicks a new day
* instead of resetting to 00:00
*/
const handleSelect = (newDay: Date | undefined) => {
if (!newDay) return;
if (!value) {
onChange?.(newDay);
setMonth(newDay);
return;
}
const diff = newDay.getTime() - value.getTime();
const diffInDays = diff / (1000 * 60 * 60 * 24);
const newDateFull = add(value, {days: Math.ceil(diffInDays)});
onChange?.(newDateFull);
setMonth(newDateFull);
};
useImperativeHandle(
ref,
() => ({
...buttonRef.current,
value,
}),
[value],
);
const initHourFormat = {
hour24:
displayFormat?.hour24 ??
`PPP HH:mm${!granularity || granularity === 'second' ? ':ss' : ''}`,
hour12:
displayFormat?.hour12 ??
`PP hh:mm${!granularity || granularity === 'second' ? ':ss' : ''} b`,
};
let loc = enUS;
const {options, localize, formatLong} = locale;
if (options && localize && formatLong) {
loc = {
...enUS,
options,
localize,
formatLong,
};
}
return (
<Popover>
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground',
className,
)}
ref={buttonRef}
>
<CalendarIcon className="mr-2 h-4 w-4"/>
{value ? (
format(value, hourCycle === 24 ? initHourFormat.hour24 : initHourFormat.hour12, {
locale: loc,
})
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={value}
month={month}
onSelect={(d) => handleSelect(d)}
onMonthChange={handleSelect}
yearRange={yearRange}
locale={locale}
{...props}
/>
{granularity !== 'day' && (
<div className="border-t border-border p-3">
<TimePicker
onChange={onChange}
date={value}
hourCycle={hourCycle}
granularity={granularity}
/>
</div>
)}
</PopoverContent>
</Popover>
);
},
);
DateTimePicker.displayName = 'DateTimePicker';
export {DateTimePicker, TimePickerInput, TimePicker};
export type {TimePickerType, DateTimePickerProps, DateTimePickerRef};
+4 -3
View File
@@ -4,7 +4,8 @@ export type LayoutParams<T extends Record<string, string | string[]>> = {
};
export type PageParams<T extends Record<string, string | string[]>> = {
params: T;
searchParams: { [key: string]: string | string[] | undefined };
};
params: Promise<T>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
+5 -5
View File
@@ -1878,10 +1878,10 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0"
is-data-view "^1.0.1"
date-fns@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
date-fns@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5:
version "4.3.7"
@@ -3640,7 +3640,7 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
react-day-picker@^8.10.1:
react-day-picker@8.10.1:
version "8.10.1"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==