* feat: setup schema for workspace roles * chore: regen migration * feat: add publicId to workspace roles * feat: setup default permissions * feat: add repo funcs * feat: setup basic router interactions * feat: add card permissions * feat: assert permissions for lists * feat: assert board permissions * feat: assert permission for remaining routes * feat: add permissions page to settings * feat: enable updating member roles * feat: order members by role and createdAt * feat: allow editing individual permissions * feat: reset role defaults * feat: clear all permission overrides * feat: allow users to delete entities they have created * feat: set roleId when inviting new members * feat: disable UI elements if user does not have permissions * feat: allow admins to assign the admin role to other users * feat: allow delete:list as default * refactor: centre permissions modal * chore: translations
145 lines
4.4 KiB
TypeScript
145 lines
4.4 KiB
TypeScript
import { t } from "@lingui/core/macro";
|
|
import { format } from "date-fns";
|
|
import { useEffect, useState } from "react";
|
|
import { HiMiniPlus } from "react-icons/hi2";
|
|
|
|
import DateSelector from "~/components/DateSelector";
|
|
import { usePopup } from "~/providers/popup";
|
|
import { api } from "~/utils/api";
|
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
|
|
interface DueDateSelectorProps {
|
|
cardPublicId: string;
|
|
dueDate: Date | null | undefined;
|
|
isLoading?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function DueDateSelector({
|
|
cardPublicId,
|
|
dueDate,
|
|
isLoading = false,
|
|
disabled = false,
|
|
}: DueDateSelectorProps) {
|
|
const { showPopup } = usePopup();
|
|
const utils = api.useUtils();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
|
dueDate,
|
|
);
|
|
|
|
// Sync pendingDate with dueDate when it changes externally
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setPendingDate(dueDate);
|
|
}
|
|
}, [dueDate, isOpen]);
|
|
|
|
const updateDueDate = api.card.update.useMutation({
|
|
onMutate: async (update) => {
|
|
await utils.card.byId.cancel();
|
|
|
|
const previousCard = utils.card.byId.getData({ cardPublicId });
|
|
|
|
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
|
|
if (!oldCard) return oldCard;
|
|
|
|
return {
|
|
...oldCard,
|
|
dueDate:
|
|
update.dueDate !== undefined
|
|
? (update.dueDate as Date | null)
|
|
: oldCard.dueDate,
|
|
};
|
|
});
|
|
|
|
return { previousCard };
|
|
},
|
|
onError: (_error, _update, context) => {
|
|
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
|
showPopup({
|
|
header: t`Unable to update due date`,
|
|
message: t`Please try again later, or contact customer support.`,
|
|
icon: "error",
|
|
});
|
|
},
|
|
onSettled: async () => {
|
|
await invalidateCard(utils, cardPublicId);
|
|
await utils.board.byId.invalidate();
|
|
},
|
|
});
|
|
|
|
const handleDateSelect = (date: Date | undefined) => {
|
|
// Only update local state, don't fire mutation
|
|
setPendingDate(date ?? null);
|
|
};
|
|
|
|
const handleBackdropClick = () => {
|
|
// Only fire mutation if date actually changed
|
|
const pendingIsNull = pendingDate === null || pendingDate === undefined;
|
|
const dueIsNull = dueDate === null || dueDate === undefined;
|
|
|
|
let dateChanged = false;
|
|
if (pendingIsNull && !dueIsNull) {
|
|
dateChanged = true;
|
|
} else if (!pendingIsNull && dueIsNull) {
|
|
dateChanged = true;
|
|
} else if (!pendingIsNull && !dueIsNull) {
|
|
// Both are non-null at this point
|
|
if (pendingDate instanceof Date && dueDate instanceof Date) {
|
|
dateChanged = pendingDate.getTime() !== dueDate.getTime();
|
|
}
|
|
}
|
|
|
|
// Close popover immediately
|
|
setIsOpen(false);
|
|
|
|
// Fire mutation if date changed (optimistic update will handle UI)
|
|
if (dateChanged) {
|
|
updateDueDate.mutate({
|
|
cardPublicId,
|
|
dueDate: pendingDate ?? null,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex w-full items-center text-left">
|
|
<button
|
|
type="button"
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
disabled={isLoading || disabled}
|
|
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
|
>
|
|
{dueDate ? (
|
|
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
|
) : (
|
|
<>
|
|
<HiMiniPlus size={22} className="pr-2" />
|
|
{t`Set due date`}
|
|
</>
|
|
)}
|
|
</button>
|
|
{isOpen && !disabled && (
|
|
<>
|
|
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
|
<div
|
|
className="absolute -left-8 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<DateSelector
|
|
selectedDate={pendingDate ?? undefined}
|
|
onDateSelect={handleDateSelect}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|