feat: card due dates (#271)
* feat: add card due dates to schema * feat: update repo funcs * feat: update card router to support due dates * chore: update migration journal * feat: add date selector * feat: display date icon and label on cards * feat: add due date filters * feat: add due date to new card form * feat: light mode tweaks * feat: improve text eligibility on light mode * feat: reduce selector font size * feat: display date updates in card activity * chore: gen translations
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
|
||||
import {
|
||||
HiBars3BottomLeft,
|
||||
HiChatBubbleLeft,
|
||||
HiOutlineClock,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
@@ -15,6 +22,7 @@ const Card = ({
|
||||
description,
|
||||
comments,
|
||||
attachments,
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
@@ -36,7 +44,11 @@ const Card = ({
|
||||
description: string | null;
|
||||
comments: { publicId: string }[];
|
||||
attachments?: { publicId: string }[];
|
||||
dueDate?: Date | null;
|
||||
}) => {
|
||||
const { dateLocale } = useLocalisation();
|
||||
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
|
||||
const isOverdue = dueDate ? isBefore(dueDate, startOfDay(new Date())) : false;
|
||||
const completedItems = checklists.reduce((acc, checklist) => {
|
||||
return acc + checklist.items.filter((item) => item.completed).length;
|
||||
}, 0);
|
||||
@@ -51,6 +63,7 @@ const Card = ({
|
||||
const hasDescription =
|
||||
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
@@ -60,6 +73,7 @@ const Card = ({
|
||||
checklists.length > 0 ||
|
||||
hasDescription ||
|
||||
comments.length > 0 ||
|
||||
hasDueDate ||
|
||||
hasAttachments ? (
|
||||
<div className="mt-2 flex flex-col justify-end">
|
||||
<div className="space-x-0.5">
|
||||
@@ -77,6 +91,23 @@ const Card = ({
|
||||
<HiBars3BottomLeft className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{hasDueDate && dueDate && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1",
|
||||
isOverdue
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-light-800 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
<HiOutlineClock className="h-4 w-4" />
|
||||
<span className="text-[11px]">
|
||||
{format(dueDate, showYear ? "do MMM yyyy" : "do MMM", {
|
||||
locale: dateLocale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||
<HiChatBubbleLeft className="h-4 w-4" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineClock,
|
||||
HiOutlineSquare3Stack3D,
|
||||
HiOutlineTag,
|
||||
HiOutlineUserCircle,
|
||||
@@ -60,7 +61,13 @@ const Filters = ({
|
||||
try {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, members: [], labels: [], lists: [] },
|
||||
query: {
|
||||
...router.query,
|
||||
members: [],
|
||||
labels: [],
|
||||
lists: [],
|
||||
dueDate: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -99,6 +106,39 @@ const Filters = ({
|
||||
selected: !!router.query.lists?.includes(list.publicId),
|
||||
}));
|
||||
|
||||
const dueDateItems = [
|
||||
{
|
||||
key: "overdue",
|
||||
value: t`Overdue`,
|
||||
selected: !!router.query.dueDate?.includes("overdue"),
|
||||
},
|
||||
{
|
||||
key: "today",
|
||||
value: t`Due today`,
|
||||
selected: !!router.query.dueDate?.includes("today"),
|
||||
},
|
||||
{
|
||||
key: "tomorrow",
|
||||
value: t`Due tomorrow`,
|
||||
selected: !!router.query.dueDate?.includes("tomorrow"),
|
||||
},
|
||||
{
|
||||
key: "next-week",
|
||||
value: t`Due next week`,
|
||||
selected: !!router.query.dueDate?.includes("next-week"),
|
||||
},
|
||||
{
|
||||
key: "next-month",
|
||||
value: t`Due next month`,
|
||||
selected: !!router.query.dueDate?.includes("next-month"),
|
||||
},
|
||||
{
|
||||
key: "no-due-date",
|
||||
value: t`No dates`,
|
||||
selected: !!router.query.dueDate?.includes("no-due-date"),
|
||||
},
|
||||
];
|
||||
|
||||
const groups = [
|
||||
...(formattedMembers.length
|
||||
? [
|
||||
@@ -126,6 +166,12 @@ const Filters = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "dueDate",
|
||||
label: t`Due date`,
|
||||
icon: <HiOutlineClock size={16} />,
|
||||
items: dueDateItems,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = async (
|
||||
@@ -156,6 +202,7 @@ const Filters = ({
|
||||
...formatToArray(router.query.members),
|
||||
...formatToArray(router.query.labels),
|
||||
...formatToArray(router.query.lists),
|
||||
...formatToArray(router.query.dueDate),
|
||||
].length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiOutlineBarsArrowDown,
|
||||
@@ -15,6 +16,7 @@ import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import Editor from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
@@ -27,6 +29,7 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
type NewCardFormInput = NewCardInput & {
|
||||
isCreateAnotherEnabled: boolean;
|
||||
dueDate?: Date | null;
|
||||
};
|
||||
|
||||
interface QueryParams {
|
||||
@@ -65,6 +68,7 @@ export function NewCardForm({
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled: false,
|
||||
position: "start",
|
||||
dueDate: null,
|
||||
},
|
||||
resetOnClose: true,
|
||||
});
|
||||
@@ -80,6 +84,8 @@ export function NewCardForm({
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
const description = watch("description");
|
||||
const dueDate = watch("dueDate");
|
||||
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
|
||||
|
||||
// saving form state whenever form values change
|
||||
useEffect(() => {
|
||||
@@ -137,6 +143,7 @@ export function NewCardForm({
|
||||
title: args.title,
|
||||
listId: 2,
|
||||
description: "",
|
||||
dueDate: args.dueDate ?? null,
|
||||
labels: oldBoard.labels.filter((label) =>
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
@@ -193,6 +200,7 @@ export function NewCardForm({
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
dueDate: null,
|
||||
};
|
||||
reset(newFormState);
|
||||
saveFormState(newFormState);
|
||||
@@ -242,7 +250,7 @@ export function NewCardForm({
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const onSubmit = (data: NewCardInput) => {
|
||||
const onSubmit = (data: NewCardFormInput) => {
|
||||
createCard.mutate({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -250,6 +258,7 @@ export function NewCardForm({
|
||||
labelPublicIds: data.labelPublicIds,
|
||||
memberPublicIds: data.memberPublicIds,
|
||||
position: data.position,
|
||||
dueDate: data.dueDate ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -448,6 +457,44 @@ export function NewCardForm({
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
<div className="relative w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDateSelectorOpen(!isDateSelectorOpen)}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
) : (
|
||||
<>{t`Due date`}</>
|
||||
)}
|
||||
</button>
|
||||
{isDateSelectorOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsDateSelectorOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 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={dueDate ?? undefined}
|
||||
onDateSelect={(date) => {
|
||||
setValue("dueDate", date ?? null);
|
||||
setIsDateSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
|
||||
import { formatToArray } from "~/utils/helpers";
|
||||
import BoardDropdown from "./components/BoardDropdown";
|
||||
import Card from "./components/Card";
|
||||
@@ -87,17 +88,28 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
};
|
||||
|
||||
const semanticFilters = formatToArray(router.query.dueDate) as (
|
||||
| "overdue"
|
||||
| "today"
|
||||
| "tomorrow"
|
||||
| "next-week"
|
||||
| "next-month"
|
||||
| "no-due-date"
|
||||
)[];
|
||||
|
||||
const queryParams: {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
dueDate: ReturnType<typeof convertDueDateFiltersToRanges>;
|
||||
type: "regular" | "template";
|
||||
} = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
lists: formatToArray(router.query.lists),
|
||||
dueDate: convertDueDateFiltersToRanges(semanticFilters),
|
||||
type: isTemplate ? "template" : "regular",
|
||||
};
|
||||
|
||||
@@ -567,6 +579,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
dueDate={card.dueDate ?? null}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -24,15 +25,6 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
const dateLocaleMap = {
|
||||
en: enGB,
|
||||
fr: fr,
|
||||
de: de,
|
||||
es: es,
|
||||
it: it,
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -47,6 +39,8 @@ const getActivityText = ({
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -56,6 +50,9 @@ const getActivityText = ({
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
fromDueDate?: Date | null;
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
@@ -74,6 +71,9 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
@@ -209,6 +209,38 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.updated" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.removed") {
|
||||
return <Trans>removed the due date</Trans>;
|
||||
}
|
||||
|
||||
return baseText;
|
||||
};
|
||||
|
||||
@@ -229,6 +261,9 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
} as const;
|
||||
|
||||
const getActivityIcon = (
|
||||
@@ -260,9 +295,7 @@ const ActivityList = ({
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||
const { dateLocale } = useLocalisation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
@@ -276,6 +309,9 @@ const ActivityList = ({
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
@@ -330,7 +366,7 @@ const ActivityList = ({
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: currentDateLocale,
|
||||
locale: dateLocale,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
141
apps/web/src/views/card/components/DueDateSelector.tsx
Normal file
141
apps/web/src/views/card/components/DueDateSelector.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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";
|
||||
|
||||
interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = 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 utils.card.byId.invalidate({ 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={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
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 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 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 && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div 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 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function MemberSelector({
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div 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 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||
import Dropdown from "./components/Dropdown";
|
||||
import { DueDateSelector } from "./components/DueDateSelector";
|
||||
import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
@@ -124,7 +125,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
@@ -133,6 +134,14 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
||||
<DueDateSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import ThemeToggle from "~/components/ThemeToggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
|
||||
import { formatToArray } from "~/utils/helpers";
|
||||
import Card from "~/views/board/components/Card";
|
||||
import Filters from "~/views/board/components/Filters";
|
||||
@@ -38,6 +39,15 @@ export default function PublicBoardView() {
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const dueDateFilters = formatToArray(router.query.dueDate) as (
|
||||
| "overdue"
|
||||
| "today"
|
||||
| "tomorrow"
|
||||
| "next-week"
|
||||
| "next-month"
|
||||
| "no-due-date"
|
||||
)[];
|
||||
|
||||
const { data, isLoading } = api.board.bySlug.useQuery(
|
||||
{
|
||||
boardSlug: boardSlug ?? "",
|
||||
@@ -45,6 +55,7 @@ export default function PublicBoardView() {
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
lists: formatToArray(router.query.lists),
|
||||
dueDate: convertDueDateFiltersToRanges(dueDateFilters),
|
||||
},
|
||||
{
|
||||
enabled: router.isReady && !!boardSlug,
|
||||
@@ -77,7 +88,7 @@ export default function PublicBoardView() {
|
||||
};
|
||||
|
||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||
const splitPath = pathWithoutQuery.split("/");
|
||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -199,6 +210,7 @@ export default function PublicBoardView() {
|
||||
description={card.description}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
dueDate={card.dueDate ?? null}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user