Skip to content

Commit

Permalink
Merge pull request #48 from richardguerre/task_tags
Browse files Browse the repository at this point in the history
Add UI/UX for task tags
  • Loading branch information
richardguerre authored Sep 1, 2024
2 parents 0c65c2b + a989ddd commit ea1be9c
Show file tree
Hide file tree
Showing 90 changed files with 1,353 additions and 2,748 deletions.
4 changes: 2 additions & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ apps/server/plugins/*
packages/relay/schema.graphql

# ignoring generated files from relay-compiler (copied from apps/web/.gitignore)
apps/web/src/relay/__generated__
apps/mobile-pwa/src/relay/__generated__
apps/web/src/relay/__gen__
apps/mobile-pwa/src/relay/__gen__

# ignoring uno.css to keep the bundle size small
apps/web/uno.css
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile-pwa/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ dev-dist
*.njsproj
*.sln
*.sw?

src/relay/__gen__
5 changes: 4 additions & 1 deletion apps/mobile-pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"setup:relay": "mkdir -p src/relay/__generated__",
"setup:relay": "mkdir -p src/relay/__gen__",
"relay": "cd ../.. && relay-compiler && echo '✅ Relay compiled!'",
"relay:watch": "cd ../.. && relay-compiler --watch",
"dev": "bun run relay && bunx --bun vite --port 5173",
Expand Down Expand Up @@ -46,5 +46,8 @@
"vite": "^5.2.10",
"vite-plugin-pwa": "^0.20.0",
"workbox-core": "^7.1.0"
},
"relay": {
"artifactDirectory": "src/relay/__gen__"
}
}
41 changes: 3 additions & 38 deletions apps/mobile-pwa/src/components/Day.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useEffect, useState } from "react";
import { graphql, useFragment, useMutation } from "@flowdev/relay";
import { Day_day$key } from "@flowdev/mobile-pwa/relay/__generated__/Day_day.graphql";
import { Day_day$key } from "@flowdev/mobile-pwa/relay/__gen__/Day_day.graphql";
import { TaskCard } from "./TaskCard";
import { dayjs } from "@flowdev/mobile-pwa/dayjs";
import { ReactSortable, Sortable } from "react-sortablejs";
import { DayContent_day$key } from "@flowdev/mobile-pwa/relay/__generated__/DayContent_day.graphql";
import { DayUpdateTaskDateMutation } from "@flowdev/mobile-pwa/relay/__generated__/DayUpdateTaskDateMutation.graphql";
import { environment } from "@flowdev/mobile-pwa/relay/environment";
import { DayContent_day$key } from "@flowdev/mobile-pwa/relay/__gen__/DayContent_day.graphql";
import { DayUpdateTaskDateMutation } from "@flowdev/mobile-pwa/relay/__gen__/DayUpdateTaskDateMutation.graphql";

type DayProps = {
day: Day_day$key;
Expand Down Expand Up @@ -132,37 +131,3 @@ const dayOfWeekArr = [
"Friday",
"Saturday",
] as const;

export const createVirtualTask = (props: { date: string }) => {
const tempId = `Task_${Math.random()}`;
environment.commitUpdate((store) => {
const createdTask = store
.create(tempId, "Task")
.setValue(tempId, "id")
.setValue("", "title")
.setValue(new Date().toISOString(), "createdAt")
.setValue("TODO", "status")
.setValue(null, "completedAt")
.setValue(props.date, "date")
.setValue(null, "item")
.setValue(null, "durationInMinutes")
.setLinkedRecords([], "pluginDatas")
.setLinkedRecords([], "subtasks");

const dayRecord = store.get(`Day_${props.date}`);
const dayTasks = dayRecord?.getLinkedRecords("tasks");
// This adds the new task to the top of the list
dayRecord?.setLinkedRecords([createdTask, ...(dayTasks ?? [])], "tasks");
});
};

export const deleteVirtualTask = (tempId: string) => {
environment.commitUpdate((store) => {
const task = store.get(tempId);
if (!task) return;
const day = store.get(`Day_${task.getValue("date")}`);
const dayTasks = day?.getLinkedRecords("tasks");
day?.setLinkedRecords(dayTasks?.filter((task) => task.getDataID() !== tempId) ?? [], "tasks");
// store.delete(tempId); this causes a render issue so for now I'm not including it.
});
};
27 changes: 16 additions & 11 deletions apps/mobile-pwa/src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import {
DropdownMenuTrigger,
} from "@flowdev/ui/DropdownMenu";
import { BsCheck, BsClock, BsX } from "@flowdev/icons";
import { TaskCard_task$key } from "@flowdev/mobile-pwa/relay/__generated__/TaskCard_task.graphql";
import { TaskCard_task$key } from "@flowdev/mobile-pwa/relay/__gen__/TaskCard_task.graphql";
import {
TaskCardUpdateTaskStatusMutation,
TaskStatus,
} from "@flowdev/mobile-pwa/relay/__generated__/TaskCardUpdateTaskStatusMutation.graphql";
import { TaskCardDeleteTaskMutation } from "../relay/__generated__/TaskCardDeleteTaskMutation.graphql";
import { TaskCardSubtask_task$key } from "../relay/__generated__/TaskCardSubtask_task.graphql";
} from "@flowdev/mobile-pwa/relay/__gen__/TaskCardUpdateTaskStatusMutation.graphql";
import { TaskCardDeleteTaskMutation } from "../relay/__gen__/TaskCardDeleteTaskMutation.graphql";
import { TaskCardSubtask_task$key } from "../relay/__gen__/TaskCardSubtask_task.graphql";
import { EditorContent, MinimumKit, useEditor } from "@flowdev/tiptap";
import { TaskCardTitle_task$key } from "../relay/__generated__/TaskCardTitle_task.graphql";
import { TaskCardTitle_task$key } from "../relay/__gen__/TaskCardTitle_task.graphql";
import "./TaskCardTitle.scss";
import { TaskCardStatusButton_task$key } from "../relay/__generated__/TaskCardStatusButton_task.graphql";
import { TaskCardStatusButton_task$key } from "../relay/__gen__/TaskCardStatusButton_task.graphql";
import { TaskTagsExtension, useTaskTags } from "./TaskTags";

type TaskCardProps = {
task: TaskCard_task$key;
Expand Down Expand Up @@ -118,6 +119,7 @@ const TaskCardStatusButton = (props: { task: TaskCardStatusButton_task$key; smal
};

const TaskCardTitle = (props: { task: TaskCardTitle_task$key }) => {
const { taskTags } = useTaskTags();
const task = useFragment(
graphql`
fragment TaskCardTitle_task on Task {
Expand All @@ -127,11 +129,14 @@ const TaskCardTitle = (props: { task: TaskCardTitle_task$key }) => {
`,
props.task,
);
const editor = useEditor({
extensions: [MinimumKit],
content: task.title,
editable: false, // readonly
});
const editor = useEditor(
{
extensions: [MinimumKit, TaskTagsExtension.configure({ tags: taskTags })],
content: task.title,
editable: false, // readonly
},
[taskTags],
);

return <EditorContent editor={editor} className="TaskCardTitleInput w-full cursor-text p-0" />;
};
Expand Down
244 changes: 244 additions & 0 deletions apps/mobile-pwa/src/components/TaskTags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { Editor, Mention, MentionOptions } from "@flowdev/tiptap";
import { ReactRenderer, mergeAttributes } from "@tiptap/react";
import tippy, { Instance as TippyInstance } from "tippy.js";
import { environment } from "@flowdev/mobile-pwa/relay/environment";
import { dayjs } from "@flowdev/mobile-pwa/dayjs";
import { TaskTagsQuery } from "@flowdev/mobile-pwa/relay/__gen__/TaskTagsQuery.graphql";
import { TaskTagsNode_tag$data } from "@flowdev/mobile-pwa/relay/__gen__/TaskTagsNode_tag.graphql";
import { TaskTagsAttrs_tag$data } from "@flowdev/mobile-pwa/relay/__gen__/TaskTagsAttrs_tag.graphql";
import { fetchQuery, graphql } from "@flowdev/relay";
import { useAsyncEffect } from "@flowdev/mobile-pwa/useAsyncEffect";

const taskTagsQuery = graphql`
query TaskTagsQuery {
taskTags {
edges {
node {
...TaskTagsNode_tag @relay(mask: false)
}
}
}
}
`;

const getTaskTags = async () => {
const lastTimeQueriedTags = localStorage.getItem("lastTimeQueriedTags");
const FIVE_MINUTES = 1000 * 60 * 5;
const hasBeenQueriedRecently = lastTimeQueriedTags
? dayjs().diff(dayjs(lastTimeQueriedTags), "millisecond") < FIVE_MINUTES
: false;

if (!hasBeenQueriedRecently) {
localStorage.setItem("lastTimeQueriedTags", dayjs().toISOString());
}

const taskTagsData = await fetchQuery<TaskTagsQuery>(
environment,
taskTagsQuery,
{},
{ fetchPolicy: hasBeenQueriedRecently ? "store-or-network" : "network-only" },
).toPromise();

return taskTagsData?.taskTags.edges.map((edge) => edge.node as TaskTagsNode) ?? [];
};

export const useTaskTags = (props?: { onLoaded?: (tags: TaskTagsNode[]) => void }) => {
const [taskTags, setTaskTags] = useState<TaskTagsNode[]>([]);
const [loading, setLoading] = useState(true);

useAsyncEffect(async () => {
setLoading(true);
const tags = await getTaskTags().finally(() => setLoading(false));
props?.onLoaded?.(tags);
setTaskTags(tags);
}, []);

return { taskTags, loading };
};

export const TaskTagsExtension = Mention.extend<
MentionOptions & { tags: TaskTagsNode[] },
{ tags: TaskTagsNode[] }
>({
name: "taskTags",
addOptions() {
return { ...this.parent?.(), tags: [] };
},
addAttributes: () => ({
id: {
default: "something",
parseHTML: (element) => element.getAttribute("data-tasktag-id") ?? null,
renderHTML: (attrs) => {
if (!attrs.id) return {};
return { "data-tasktag-id": attrs.id };
},
},
name: {
default: null,
parseHTML: (element) => element.getAttribute("data-name") ?? null,
renderHTML: (attrs) => {
if (!attrs.name) return {};
return { "data-name": attrs.name };
},
},
}),
parseHTML: () => [{ tag: "span[data-tasktag-id]" }],
renderHTML({ node, HTMLAttributes }) {
const taskTagAttrs = node.attrs as TaskTagsAttrs;
const taskTag = this.options.tags.find((tag) => tag.id === taskTagAttrs.id);
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: `text-${taskTag?.color ?? "gray"}-700 rounded-md px-1 py-0.5`,
}),
`${this.options.suggestion.char}${taskTag?.name ?? taskTagAttrs.name}`,
];
},
}).configure({
suggestion: {
char: "#",
items: async ({ query }) => {
const taskTagsData = await getTaskTags();

return taskTagsData
.filter((tag) => tag.name.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
},

render: () => {
let component: ReactRenderer<any, any>;
let popup: TippyInstance<any>;

return {
onStart: (props) => {
component = new ReactRenderer(TaskTagsList, {
props,
editor: props.editor,
});

if (!props.clientRect) {
return;
}

// @ts-ignore as tippy's types are incorrect (the first function overload is used instead of the second)
[popup] = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},

onUpdate(props) {
component.updateProps(props);

if (!props.clientRect) {
return;
}

popup.setProps({
getReferenceClientRect: props.clientRect,
});
},

onKeyDown(props) {
if (props.event.key === "Escape") {
popup.hide();

return true;
}

return component.ref?.onKeyDown(props);
},

onExit() {
popup.destroy();
component.destroy();
},
};
},
},
});

const TaskTagsList = forwardRef(
(
props: {
items: TaskTagsNode_tag$data[];
range: { from: number; to: number };
editor: Editor;
},
ref,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);

const selectItem = (index: number) => {
const item = props.items[index];

if (item) {
props.editor
.chain()
.deleteRange({ from: props.range.from, to: props.range.to })
.insertContent({ type: "taskTags", attrs: item })
.run();
}
};

useEffect(() => setSelectedIndex(0), [props.items]);

useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
return true;
} else if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % props.items.length);
return true;
} else if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));

return (
<div className="flex flex-col gap-1 rounded-md border border-gray-200 bg-white p-1">
{props.items.length ? (
props.items.map((item, index) => (
<button
className={index === selectedIndex ? "bg-primary-200" : ""}
key={index}
onClick={() => selectItem(index)}
>
{item.name}
</button>
))
) : (
<div className="item">No result</div>
)}
</div>
);
},
);

type TaskTagsNode = TaskTagsNode_tag$data;
graphql`
fragment TaskTagsNode_tag on TaskTag {
id
name
color
...TaskTagsAttrs_tag @relay(mask: false)
}
`;

type TaskTagsAttrs = TaskTagsAttrs_tag$data;
graphql`
fragment TaskTagsAttrs_tag on TaskTag {
id
name
}
`;
Loading

0 comments on commit ea1be9c

Please sign in to comment.