Compare commits

...

2 Commits

Author SHA1 Message Date
Prajwal Moharana
dff05af79c
Add single tag selection and editting row (#52) 2025-01-04 15:10:07 -05:00
Prajwal Moharana
251222167d
New btn moharana (#51)
* Implement 'Create New' button and fix no tags bug

* Implement local state editting when creating new element

* Add defaults when no tags

* Reset tags whenever new item is created
2025-01-04 13:34:26 -05:00
10 changed files with 521 additions and 135 deletions

View File

@ -0,0 +1,233 @@
import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react";
import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index";
import { Details } from "./Drawer";
type CreateDrawerProps = {
details: Details[];
onCreate: (newItem: any) => boolean;
};
const CreateDrawer: FunctionComponent<CreateDrawerProps> = ({
details,
onCreate,
}: CreateDrawerProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false);
const [newItemContent, setNewItemContent] = useState<any>({});
const [renderKey, setRenderKey] = useState(0);
const handleContentChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
console.log(newItemContent);
console.log(Object.keys(newItemContent).length);
setNewItemContent((prev: any) => ({
...prev,
[name]: value,
}));
};
const initializeSelectField = (key: string) => {
if (!newItemContent[key]) {
setNewItemContent((prev: any) => ({
...prev,
[key]: [],
}));
}
};
const handleCreate = () => {
if (onCreate(newItemContent)) {
setNewItemContent({});
setIsOpen(false);
}
};
const toggleDrawer = () => {
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
if (!isOpen) {
setRenderKey((prev) => prev + 1);
}
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
const drawerClassName = `fixed top-0 right-0 h-full bg-white transform ease-in-out duration-300 z-20 overflow-y-auto ${
isOpen ? "translate-x-0 shadow-2xl" : "translate-x-full"
} ${isFull ? "w-full" : "w-[600px]"}`;
const iconComponent = isFull ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
);
return (
<div>
<button
className="text-sm text-white font-medium bg-purple-600 hover:bg-purple-700 px-3 py-1 rounded-md"
onClick={toggleDrawer}
>
Create New
</button>
<div className={drawerClassName}>
<div className="sticky top-0 flex items-center justify-between p-4 bg-white border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">
Create New Item
</h2>
<div className="flex items-center space-x-2">
<button
onClick={toggleDrawerFullScreen}
className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
{iconComponent}
</button>
<button
onClick={toggleDrawer}
className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-6">
<div className="flex flex-col space-y-4">
{details.map((detail, index) => {
const value = newItemContent[detail.key] || "";
let inputField;
switch (detail.inputType) {
case "select-one":
initializeSelectField(detail.key);
inputField = (
<TagsInput
key={`${detail.key}-${renderKey}`}
presetValue={[]}
presetOptions={
detail.presetOptionsValues || []
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
singleValue={true}
onTagsChange={(
tags: Set<string>
) => {
setNewItemContent(
(prev: any) => ({
...prev,
[detail.key]:
tags.size > 0
? Array.from(
tags
)[0]
: null,
})
);
}}
/>
);
break;
case "select-multiple":
initializeSelectField(detail.key);
inputField = (
<TagsInput
key={`${detail.key}-${renderKey}`}
presetValue={
newItemContent[detail.key] || []
}
presetOptions={
detail.presetOptionsValues || []
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
onTagsChange={(
tags: Set<string>
) => {
setNewItemContent(
(prev: any) => ({
...prev,
[detail.key]:
Array.from(tags),
})
);
}}
/>
);
break;
case "textarea":
inputField = (
<textarea
name={detail.key}
value={value}
onChange={handleContentChange}
rows={4}
onInput={(e) => {
const target =
e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height =
target.scrollHeight + "px";
}}
className="w-full p-2 focus:outline-none border border-gray-200 rounded-md resize-none font-normal"
placeholder={`Enter ${detail.label.toLowerCase()}...`}
/>
);
break;
default:
inputField = (
<input
type={detail.inputType}
name={detail.key}
value={value}
onChange={handleContentChange}
className="w-full p-2 border border-gray-200 rounded-md focus:outline-none focus:border-purple-500"
placeholder={`Enter ${detail.label.toLowerCase()}...`}
/>
);
}
return (
<div
key={index}
className="flex flex-col gap-2"
>
<label className="flex items-center text-sm text-gray-700 gap-2">
<span className="text-gray-500">
{detail.icon}
</span>
{detail.label}
</label>
{inputField}
</div>
);
})}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"
>
Create
</button>
</div>
</div>
</div>
</div>
);
};
export default CreateDrawer;

View File

@ -48,12 +48,14 @@ const Drawer: FunctionComponent<DrawerProps> = ({
const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent);
const onRowUpdate = (updatedRow: any) => {};
const handleTempRowContentChange = (
const handleTempRowContentChangeHTML = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
handleTempRowContentChange(name, value);
};
const handleTempRowContentChange = (name: string, value: any) => {
setTempRowContent((prev: any) => ({
...prev,
[name]: value,
@ -68,10 +70,22 @@ const Drawer: FunctionComponent<DrawerProps> = ({
};
const toggleDrawer = () => {
if (setRowContent && isOpen) {
setRowContent((prev: any) => {
return prev.map((row: any) => {
if (row.id === tempRowContent.id) {
return tempRowContent;
}
return row;
});
});
}
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
console.log("Send API request to update row content");
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
@ -143,16 +157,15 @@ const Drawer: FunctionComponent<DrawerProps> = ({
switch (detail.inputType) {
case "select-one":
case "select-multiple":
valueToRender = (
<div className="flex-1">
<div className="hover:bg-gray-50 rounded-md px-2 py-1">
<div className="rounded-md px-2 py-1">
<TagsInput
presetValue={
typeof value ===
"string"
? [value]
: value
: value || []
}
presetOptions={
detail.presetOptionsValues ||
@ -162,6 +175,51 @@ const Drawer: FunctionComponent<DrawerProps> = ({
detail.presetOptionsSetter ||
(() => {})
}
singleValue={true}
onTagsChange={(
tags: Set<string>
) => {
const tagsArray =
Array.from(tags);
handleTempRowContentChange(
detail.key,
tagsArray.length > 0
? tagsArray[0]
: null
);
}}
/>
</div>
</div>
);
break;
case "select-multiple":
valueToRender = (
<div className="flex-1">
<div className="rounded-md px-2 py-1">
<TagsInput
presetValue={
typeof value ===
"string"
? [value]
: value || []
}
presetOptions={
detail.presetOptionsValues ||
[]
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
onTagsChange={(
tags: Set<string>
) => {
handleTempRowContentChange(
detail.key,
Array.from(tags)
);
}}
/>
</div>
</div>
@ -175,7 +233,7 @@ const Drawer: FunctionComponent<DrawerProps> = ({
name={detail.key}
value={value}
onChange={
handleTempRowContentChange
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
rows={4}
@ -203,7 +261,7 @@ const Drawer: FunctionComponent<DrawerProps> = ({
name={detail.key}
value={value}
onChange={
handleTempRowContentChange
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
className="w-full p-1 focus:outline-gray-200 bg-transparent"
@ -221,7 +279,7 @@ const Drawer: FunctionComponent<DrawerProps> = ({
name={detail.key}
value={value}
onChange={
handleTempRowContentChange
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
className="w-full p-1 font-normal hover:text-gray-400 focus:outline-gray-200 underline text-gray-500 bg-transparent"

View File

@ -33,7 +33,7 @@ export const FilterBox = () => {
>
<span>{tag}</span>
<span
className="ml-2 cursor-pointer"
className="cursor-pointer"
onClick={() => handleTagChange(tag)}
>
&times;

View File

@ -106,7 +106,11 @@ export default function ResourceTable({ data, setData }: ResourceTableProps) {
),
cell: (info) => (
<div className="flex flex-wrap gap-2 items-center px-2">
<Tag>{info.getValue()}</Tag>
<Tag>
{info.getValue().length != 0
? info.getValue()
: "no program"}
</Tag>
</div>
),
}),
@ -127,5 +131,12 @@ export default function ResourceTable({ data, setData }: ResourceTableProps) {
}),
];
return <Table data={data} setData={setData} columns={columns} />;
return (
<Table
data={data}
setData={setData}
columns={columns}
details={resourceDetails}
/>
);
}

View File

@ -115,7 +115,11 @@ export default function ServiceTable({ data, setData }: ServiceTableProps) {
),
cell: (info) => (
<div className="flex flex-wrap gap-2 items-center px-2">
<Tag>{info.getValue()}</Tag>
<Tag>
{info.getValue().length != 0
? info.getValue()
: "no program"}
</Tag>
</div>
),
}),
@ -128,9 +132,13 @@ export default function ServiceTable({ data, setData }: ServiceTableProps) {
),
cell: (info) => (
<div className="flex flex-wrap gap-2 items-center px-2">
{info.getValue().map((tag: string, index: number) => {
return <Tag key={index}>{tag}</Tag>;
})}
{info.getValue().length > 0 ? (
info.getValue().map((tag: string, index: number) => {
return <Tag key={index}>{tag}</Tag>;
})
) : (
<Tag>no requirements</Tag>
)}
</div>
),
}),
@ -151,5 +159,12 @@ export default function ServiceTable({ data, setData }: ServiceTableProps) {
}),
];
return <Table data={data} setData={setData} columns={columns} />;
return (
<Table
data={data}
setData={setData}
columns={columns}
details={serviceDetails}
/>
);
}

View File

@ -19,11 +19,33 @@ import { PlusIcon } from "@heroicons/react/24/solid";
import { rankItem } from "@tanstack/match-sorter-utils";
import { RowOptionMenu } from "./RowOptionMenu";
import DataPoint from "@/utils/models/DataPoint";
import CreateDrawer from "../Drawer/CreateDrawer";
import { Details } from "../Drawer/Drawer";
type TableProps<T extends DataPoint> = {
data: T[];
setData: Dispatch<SetStateAction<T[]>>;
columns: ColumnDef<T, any>[];
details: Details[];
};
/** Validates that all required fields in a new item have values */
const validateNewItem = (newItem: any, details: Details[]): boolean => {
const hasEmptyFields = details.some((detail) => {
const value = newItem[detail.key];
return (
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0)
);
});
if (hasEmptyFields) {
alert("Please fill in all fields before creating a new item");
return false;
}
return true;
};
/** Fuzzy search function */
@ -53,46 +75,43 @@ export default function Table<T extends DataPoint>({
data,
setData,
columns,
details,
}: TableProps<T>) {
const columnHelper = createColumnHelper<T>();
/** Sorting function based on visibility */
const visibilitySort = (a: T, b: T) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1;
// /** Sorting function based on visibility */
// const visibilitySort = (a: T, b: T) =>
// a.visible === b.visible ? 0 : a.visible ? -1 : 1;
// Sort data on load
useEffect(() => {
setData((prevData) => prevData.sort(visibilitySort));
}, [setData]);
// // Sort data on load
// useEffect(() => {
// setData((prevData) => prevData.sort(visibilitySort));
// }, [setData]);
// Data manipulation methods
// TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
const deleteData = (dataId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((data) => data.id !== dataId)
);
};
// // Data manipulation methods
// // TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
// const deleteData = (dataId: number) => {
// console.log(data);
// setData((currentData) =>
// currentData.filter((data) => data.id !== dataId)
// );
// };
const hideData = (dataId: number) => {
console.log(`Toggling visibility for data with ID: ${dataId}`);
setData((currentData) => {
const newData = currentData
.map((data) =>
data.id === dataId
? { ...data, visible: !data.visible }
: data
)
.sort(visibilitySort);
// const hideData = (dataId: number) => {
// console.log(`Toggling visibility for data with ID: ${dataId}`);
// setData((currentData) => {
// const newData = currentData
// .map((data) =>
// data.id === dataId
// ? { ...data, visible: !data.visible }
// : data
// )
// .sort(visibilitySort);
console.log(newData);
return newData;
});
};
const addData = () => {
setData([...data]);
};
// console.log(newData);
// return newData;
// });
// };
// Add data manipulation options to the first column
columns.unshift(
@ -100,8 +119,10 @@ export default function Table<T extends DataPoint>({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => deleteData(props.row.original.id)}
onHide={() => hideData(props.row.original.id)}
onDelete={() => {}}
onHide={() => {}}
// onDelete={() => deleteData(props.row.original.id)}
// onHide={() => hideData(props.row.original.id)}
/>
),
})
@ -114,11 +135,6 @@ export default function Table<T extends DataPoint>({
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
@ -138,16 +154,6 @@ export default function Table<T extends DataPoint>({
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
@ -208,14 +214,21 @@ export default function Table<T extends DataPoint>({
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
className="p-3 border-y border-gray-200"
colSpan={100}
onClick={addData}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
<CreateDrawer
details={details}
onCreate={(newItem) => {
if (!validateNewItem(newItem, details)) {
return false;
}
newItem.visible = true;
setData((prev) => [...prev, newItem]);
return true;
}}
/>
</td>
</tr>
</tfoot>

View File

@ -97,8 +97,12 @@ export default function UserTable({ data, setData }: UserTableProps) {
</>
),
cell: (info) => (
<div className="flex ml-2 flex-wrap gap-2 items-center">
<Tag>{info.getValue()}</Tag>
<div className="flex flex-wrap gap-2 items-center px-2">
<Tag>
{info.getValue() && info.getValue().length != 0
? info.getValue()
: "no role"}
</Tag>
</div>
),
}),
@ -122,13 +126,24 @@ export default function UserTable({ data, setData }: UserTableProps) {
),
cell: (info) => (
<div className="flex ml-2 flex-wrap gap-2 items-center">
{info.getValue().map((tag: string, index: number) => {
return <Tag key={index}>{tag}</Tag>;
})}
{info.getValue().length > 0 ? (
info.getValue().map((tag: string, index: number) => {
return <Tag key={index}>{tag}</Tag>;
})
) : (
<Tag>no programs</Tag>
)}
</div>
),
}),
];
return <Table<User> data={data} setData={setData} columns={columns} />;
return (
<Table<User>
data={data}
setData={setData}
columns={columns}
details={userDetails}
/>
);
}

View File

@ -8,12 +8,16 @@ interface TagsInputProps {
presetOptions: string[];
presetValue: string[];
setPresetOptions: Dispatch<SetStateAction<string[]>>;
onTagsChange?: (tags: Set<string>) => void;
singleValue?: boolean;
}
const TagsInput: React.FC<TagsInputProps> = ({
presetValue,
presetOptions,
setPresetOptions,
onTagsChange,
singleValue = false,
}) => {
const [inputValue, setInputValue] = useState("");
const [cellSelected, setCellSelected] = useState(false);
@ -63,6 +67,10 @@ const TagsInput: React.FC<TagsInputProps> = ({
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
if (singleValue && tags.size >= 1) {
// Don't add new tag if we're in single value mode and already have a tag
return;
}
addTag(e);
}
};
@ -70,23 +78,32 @@ const TagsInput: React.FC<TagsInputProps> = ({
const addTag = (e?: React.KeyboardEvent<HTMLInputElement>) => {
e?.stopPropagation();
const newTags = new Set(Array.from(tags).concat(inputValue));
setOptions(new Set(Array.from(options).concat(inputValue)));
setTags(new Set(Array.from(tags).concat(inputValue)));
setTags(newTags);
setFilteredOptions(new Set(Array.from(options).concat(inputValue)));
setInputValue("");
onTagsChange?.(newTags);
};
const handleSelectTag = (tagToAdd: string) => {
console.log(tagToAdd);
console.log(tags);
if (!tags.has(tagToAdd)) {
setTags(new Set(Array.from(tags).concat(tagToAdd)));
if (singleValue) {
const newTags = new Set([tagToAdd]);
setTags(newTags);
onTagsChange?.(newTags);
} else if (!tags.has(tagToAdd)) {
const newTags = new Set(Array.from(tags).concat(tagToAdd));
setTags(newTags);
onTagsChange?.(newTags);
}
};
const handleDeleteTag = (tagToDelete: string) => {
setTags(new Set(Array.from(tags).filter((tag) => tag !== tagToDelete)));
const newTags = new Set(
Array.from(tags).filter((tag) => tag !== tagToDelete)
);
setTags(newTags);
onTagsChange?.(newTags);
};
const handleDeleteTagOption = (tagToDelete: string) => {
@ -144,31 +161,45 @@ const TagsInput: React.FC<TagsInputProps> = ({
active
tags={tags}
/>
<input
type="text"
value={inputValue}
placeholder="Search for an option..."
onChange={handleInputChange}
onKeyDown={handleAddTag}
className="focus:outline-none bg-transparent"
autoFocus
/>
{(!singleValue || tags.size === 0) && (
<input
type="text"
value={inputValue}
placeholder={
singleValue && tags.size > 0
? ""
: "Search for an option..."
}
onChange={handleInputChange}
onKeyDown={handleAddTag}
className="focus:outline-none bg-transparent"
autoFocus
/>
)}
</div>
<div className="flex rounded-b-md bg-white flex-col border-t border-gray-100 text-2xs font-medium text-gray-500 p-2">
<p className="capitalize">
Select an option or create one
{singleValue && tags.size > 0
? "Only one option can be selected"
: "Select an option or create one"}
</p>
<TagDropdown
handleDeleteTag={handleDeleteTagOption}
handleEditTag={handleEditTag}
handleAdd={handleSelectTag}
tags={filteredOptions}
/>
{inputValue.length > 0 && (
<CreateNewTagAction
input={inputValue}
addTag={addTag}
/>
{(!singleValue || tags.size === 0) && (
<>
<TagDropdown
handleDeleteTag={
handleDeleteTagOption
}
handleEditTag={handleEditTag}
handleAdd={handleSelectTag}
tags={filteredOptions}
/>
{inputValue.length > 0 && (
<CreateNewTagAction
input={inputValue}
addTag={addTag}
/>
)}
</>
)}
</div>
</div>

View File

@ -16,21 +16,27 @@ export const TagDropdown = ({
}: TagDropdownProps) => {
return (
<div className="z-50 flex flex-col space-y-2 mt-2">
{Array.from(tags).map((tag, index) => (
<div
key={index}
className="items-center rounded-md p-1 flex flex-row justify-between hover:bg-gray-100"
>
<button onClick={() => handleAdd(tag)}>
<Tag>{tag}</Tag>
</button>
<DropdownAction
handleDeleteTag={handleDeleteTag}
handleEditTag={handleEditTag}
tag={tag}
/>
{Array.from(tags).length > 0 ? (
Array.from(tags).map((tag, index) => (
<div
key={index}
className="items-center rounded-md p-1 flex flex-row justify-between hover:bg-gray-100"
>
<button onClick={() => handleAdd(tag)}>
<Tag>{tag}</Tag>
</button>
<DropdownAction
handleDeleteTag={handleDeleteTag}
handleEditTag={handleEditTag}
tag={tag}
/>
</div>
))
) : (
<div className="text-gray-500 text-sm p-1">
No options available. Type to create new ones.
</div>
))}
)}
</div>
);
};

View File

@ -7,21 +7,25 @@ export interface Tags {
}
export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => {
// console.log(tags);
return (
<div className="flex ml-2 flex-wrap gap-2 items-center">
{Array.from(tags).map((tag, index) => {
return (
<Tag
handleDelete={handleDelete}
active={active}
key={index}
>
{tag}
</Tag>
);
})}
<div className="flex flex-wrap gap-2 items-center min-h-[24px] min-w-[100px] rounded-md hover:bg-gray-100 p-1">
{Array.from(tags).length > 0 ? (
Array.from(tags).map((tag, index) => {
return (
<Tag
handleDelete={handleDelete}
active={active}
key={index}
>
{tag}
</Tag>
);
})
) : (
<span className="text-gray-400 text-sm cursor-pointer">
Click to select tags
</span>
)}
</div>
);
};