can't figure out shared state issue--the updated composition won't show on the composer. moving on to dnd for promptitem orders and promptitem hiding.

This commit is contained in:
Jordan
2024-03-02 06:36:38 -08:00
parent 326f3788fa
commit bef7b8e2cf
19 changed files with 285 additions and 125 deletions

View File

@ -0,0 +1,37 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CategoryFilter } from "./CategoryFilter";
import { $library, Category, LibraryItem } from "../lib/prompt";
const mockOnFiltered = jest.fn();
const library: LibraryItem[] = [
{ id: "1", prompt: "Hello", category: Category.style },
{ id: "2", prompt: "World", category: Category.vibes },
];
describe("CategoryFilter", () => {
beforeEach(() => {
$library.set(library);
});
it("renders checkboxes for each category", () => {
render(<CategoryFilter onFiltered={mockOnFiltered} />);
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("filters the library based on checked categories", async () => {
render(<CategoryFilter onFiltered={mockOnFiltered} />);
const styleCheckbox = screen.getByLabelText("style");
const vibesCheckbox = screen.getByLabelText("vibes");
userEvent.click(styleCheckbox);
userEvent.click(vibesCheckbox);
expect(mockOnFiltered).toHaveBeenCalledWith([library[0]]);
});
});

View File

@ -0,0 +1,44 @@
import React, { useState } from "react";
import { $library, Category, LibraryItem } from "../lib/prompt";
import { useStore } from "@nanostores/react";
import { title } from "../lib/util";
export type CategoryToggle = {[key in Category]? : boolean}
interface CategoryFilterProps {
onFiltered: (filteredLibrary: LibraryItem[]) => void;
}
export function CategoryFilter(props: CategoryFilterProps) {
const library = useStore($library);
const cats = Object.keys(Category);
const [categoryToggle, setCategoryToggle] = useState(
Object.fromEntries(
Array(cats.length).map(
(_, i) => [cats[i] as Category, true]
)
) as CategoryToggle
);
const handleCheckboxChange = (category: Category) => {
setCategoryToggle({
...categoryToggle,
[category]: !(categoryToggle[category]),
})
props.onFiltered(library.filter((item) => categoryToggle[item.category]))
};
return (
<div>
{Object.values(Category).map((category) => (
<label key={category} onClick={() => handleCheckboxChange(category)}>
<input
type="checkbox"
checked={categoryToggle[category]}
/>
{title(category)}
</label>
))}
</div>
);
}

View File

@ -0,0 +1,3 @@
.new-item-form div {
margin: 5pt;
}

View File

@ -1,11 +1,16 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import {NewLibraryItem} from './NewLibraryItem';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { NewLibraryItem } from './NewLibraryItem';
import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt';
jest.mock('../lib/prompt', () => ({
addItemToLibrary: jest.fn(),
categoryHasName: jest.fn(),
Category: {
subject: "subject",
vibes: "vibes",
medium: "medium",
},
}));
describe('NewLibraryItem', () => {
@ -26,21 +31,9 @@ describe('NewLibraryItem', () => {
it('changes the category', () => {
render(<NewLibraryItem />);
fireEvent.change(screen.getByLabelText('Prompt Item Category'), {
target: { value: Category.vibes },
});
fireEvent.click(screen.getByLabelText('Prompt Item Category').querySelectorAll('option')[1]);
expect(screen.getByLabelText('Prompt Item Name')).toBeVisible();
});
it('changes the name', () => {
render(<NewLibraryItem />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Test' },
});
expect(screen.getByDisplayValue('Test')).toBeInTheDocument();
expect(screen.getByLabelText('Prompt Item Name')).toBeInTheDocument();
});
it('changes the prompt', () => {
@ -53,16 +46,19 @@ describe('NewLibraryItem', () => {
expect(screen.getByDisplayValue('Test')).toBeInTheDocument();
});
it('creates a new library item', () => {
it('creates a new library item', async () => {
render(<NewLibraryItem />);
fireEvent.change(screen.getByLabelText('Category'), {
target: { value: Category.subject },
await act(async () => {
fireEvent.click(screen.getByLabelText('Prompt Item Category').querySelectorAll('option')[1]);
});
fireEvent.change(screen.getByLabelText('Name'), {
await waitFor(() => {
expect(screen.getByLabelText('Prompt Item Name')).toBeInTheDocument();
})
fireEvent.change(screen.getByLabelText('Prompt Item Name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByLabelText('Prompt'), {
fireEvent.change(screen.getByLabelText('Prompt Item Text'), {
target: { value: 'Test' },
});
fireEvent.click(screen.getByText('Create'));

View File

@ -1,8 +1,10 @@
import { Button, Container, FormControl, InputLabel, MenuItem, Table, TableRow, TextField } from "@material-ui/core";
import { Button, Container, FormControl, InputLabel, TextField } from "@material-ui/core";
import Select, { SelectChangeEvent } from '@mui/material/Select';
import { Category, LibraryItem, addItemToLibrary, categoryHasName } from "../lib/prompt";
import { ChangeEvent, useState } from "react";
import { v4 as uuidv4 } from "uuid"
import { Stack } from "@mui/material";
import "./NewLibraryItem.css"
export interface NewLibraryItemProps {
onNewCreated?: () => void;
@ -47,27 +49,43 @@ export function NewLibraryItem(props: NewLibraryItemProps) {
return (
<Container className="new-item-form">
<FormControl onSubmitCapture={handleCreateItem}>
<InputLabel htmlFor="new-prompt-category">Category</InputLabel>
<FormControl>
<InputLabel id="category-select-label">Category</InputLabel>
<Select
native
id="new-prompt-category"
aria-label="Prompt Item Category"
labelId="category-select-label"
id="category-select"
value={category}
onChange={(e) => handleCategoryChange(e)}
label="Category"
onChange={handleCategoryChange}
>
{catChoices.map(cat => (
<option value={cat} id={cat} key={cat}>{titleCase(cat)}</option>
{catChoices.map(c => (
<option key={c} value={c}>{titleCase(c)}</option>
))}
</Select>
{categoryHasName(category) && <>
<InputLabel htmlFor="name">Name</InputLabel>
<TextField aria-label="Prompt Item Name" value={name} onChange={handleNameChange} id="name" />
</>}
<InputLabel htmlFor="prompt">Prompt</InputLabel>
<TextField aria-label="Prompt Item Text" value={prompt} onChange={handlePromptChange} id="prompt" />
<Button onClick={handleCreateItem} >Create</Button>
</FormControl>
<FormControl>
<InputLabel id="name-textfield-label">Name</InputLabel>
<TextField
itemID="name-textfield-label"
id="name-textfield"
value={name}
onChange={handleNameChange}
hidden={category === Category.subject || category === Category.medium}
/>
</FormControl>
<FormControl>
<InputLabel id="prompt-textfield-label">Prompt</InputLabel>
<TextField
itemID="prompt-textfield-label"
id="prompt-textfield"
value={prompt}
onChange={handlePromptChange}
/>
</FormControl>
<Stack direction="row" spacing={2}>
<Button variant="contained" onClick={handleCreateItem}>Create</Button>
</Stack>
</Container>
)
}

View File

@ -18,10 +18,6 @@ export default function Nugget(props: NuggetProps) {
const { nugget,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
onMouseEnter,
onMouseLeave,
isTopLevel,
} = props;

View File

@ -4,7 +4,7 @@ import AddIcon from '@mui/icons-material/Add';
import "./PromptComposer.css";
import { PromptLibrary } from './PromptLibrary';
import React, { useEffect, useState } from 'react';
import { $slottedComposition, LibraryItem, PromptItem, addToOperation, insertIntoComposition, itemIsNugget, itemIsOperation, lassoNuggets } from '../lib/prompt';
import { $composition, $library, $slottedComposition, LibraryItem, PromptItem, addToOperation, insertIntoComposition, itemIsNugget, itemIsOperation, lassoNuggets, Composition } from '../lib/prompt';
import { Category } from '@mui/icons-material';
import { useStore } from '@nanostores/react'
import Nugget from './Nugget';
@ -18,7 +18,7 @@ export interface PromptComposerProps {
}
export default function PromptComposer(props: PromptComposerProps) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true);
const handleClickOpen = () => {
setOpen(true);
@ -32,10 +32,28 @@ export default function PromptComposer(props: PromptComposerProps) {
insertIntoComposition(item);
}
const slottedComposition = useStore($slottedComposition);
// const composition = useStore($composition);
const [composition, setComposition] = useState($composition.get())
useEffect(() => {
$composition.subscribe((comp) => {
console.log("composition changed!");
setComposition(comp as Composition);
});
console.log("subscribe -- composition")
})
/**
*
* @param promptItem The prompt item that we're rendering
* @param key The key
* @returns Either a Nugget or an Operation component, relating to their native types.
*/
const promptItemFactory = (promptItem: PromptItem, key: string) => {
// These callbacks are mostly for drag-n-drop functionality.
// they will be different based on whether the source or target
// is either a Nugget or an Operation.
const callbacks = {
onDragStart: (item: PromptItem) => {
if (itemIsNugget(promptItem)) {
@ -61,7 +79,7 @@ export default function PromptComposer(props: PromptComposerProps) {
completeDrop();
},
onDragEnd: (item: PromptItem) => {
},
onMouseEnter: (item: PromptItem) => {
},
@ -89,11 +107,7 @@ export default function PromptComposer(props: PromptComposerProps) {
</div>
<div>
{
slottedComposition.map((itemCol, i) => (
<>
{itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))}
</>
))
composition.map(c => promptItemFactory(c, `item-${c.id}`))
}
</div>
</div>

View File

@ -1,11 +1,12 @@
import { Button, Checkbox, Dialog, DialogTitle } from "@material-ui/core";
import { LibraryItem as LibItemType, $library, Category } from "../lib/prompt";
import { LibraryItem } from "./LibraryItem";
import { ChangeEvent, useState } from "react";
import { Button, Dialog, DialogTitle } from "@material-ui/core";
import { LibraryItem as LibItemType, $library, Category, LibraryItem, insertIntoComposition } from "../lib/prompt";
import { MouseEvent, useMemo, useState } from "react";
import { useStore } from "@nanostores/react";
import { ExitToAppOutlined } from "@mui/icons-material";
import { NewLibraryItem } from "./NewLibraryItem";
import { DataGrid, GridApi, GridColDef, GridColTypeDef } from '@mui/x-data-grid';
import "./PromptLibrary.css"
import { title } from "../lib/util";
import { Add } from "@mui/icons-material";
export interface SimpleDialogProps {
open: boolean;
@ -14,18 +15,6 @@ export interface SimpleDialogProps {
onInsertItem: (item: LibItemType) => void,
}
function title(text: string) {
return (!text.length) ? "" : ((text.length === 1) ? text.toUpperCase() : text[0].toUpperCase() + text.substring(1).toLowerCase());
}
const hide = ($el: Element) => {
if (!$el.classList.contains("hidden")) $el.classList.add("hidden");
}
const show = ($el: Element) => {
if (!$el.classList.contains("hidden")) $el.classList.remove("hidden");
}
export function PromptLibrary(props: SimpleDialogProps) {
const { open, onInsertItem, onClose } = props;
@ -36,33 +25,6 @@ export function PromptLibrary(props: SimpleDialogProps) {
const [visibleCategories, setVisibleCategories] = useState(Object.keys(Category) as Category[]);
const handleOnAddItem = (item: LibItemType) => {
// onAddItem(item);
}
const handleOnInsertItem = (item: LibItemType) => {
onInsertItem(item);
}
const filterCat = (catKey: string) => {
document.querySelectorAll(`.library-item`).forEach($el => {
console.log("Found %x", $el);
show($el);
});
if (visibleCategories.includes(catKey as Category)) {
setVisibleCategories(visibleCategories.filter((v) => v !== catKey));
} else {
setVisibleCategories([...visibleCategories, catKey as Category]);
}
console.log(visibleCategories);
document.querySelectorAll(`.library-item`).forEach($el => {
Object.keys(Category).forEach(c => {
if (c in visibleCategories) show($el)
else hide($el)
})
});
}
const handleClose = () => {
onClose();
}
@ -73,28 +35,46 @@ export function PromptLibrary(props: SimpleDialogProps) {
const categoryChoices = Object.keys(Category);
const filteredLibrary = useMemo(() => {
return library.filter(item => visibleCategories.includes(item.category));
}, [library, visibleCategories]);
const columns: GridColDef[] = [
{
field: "insertPrompt", width: 50, renderCell: (params) => {
const handleClick = ($e: MouseEvent<any>) => {
console.log(`clicked!`);
$e.stopPropagation();
const libItem = library.find(l => l.id === params.id) as LibItemType;
console.log("Inserting %o into composition", libItem);
libItem ?? onInsertItem(libItem);
}
return (
<Button onClick={handleClick}>
<Add />
</Button>
);
}
},
{ field: 'name', headerName: 'Name', width: 150 },
{ field: 'prompt', headerName: 'Prompt', width: 250 },
{ field: 'category', headerName: 'Category', width: 150 },
];
const rows = filteredLibrary.map(item => ({
id: item.id,
name: item.name || "",
prompt: item.prompt,
category: title(item.category),
}));
return (
<Dialog className="prompt-library-dialog" onClose={handleClose} open={open}>
<DialogTitle>Prompt Library</DialogTitle>
<div className="categories">
{categoryChoices.map(catKey => {
return (
<div key={catKey} onMouseDown={() => filterCat(catKey)}>
<Checkbox checked={visibleCategories.includes(catKey as Category)} />
<span>{title(catKey)}</span>
</div>
)
})}
</div>
<div>
{
library?.map(item => <LibraryItem key={item.id} item={item} onInsertItem={handleOnInsertItem} />)
}
<DataGrid rows={rows} columns={columns} />
</div>
<div>
<Button onClick={() => setDoCreate(true)}>Create</Button>
</div>
{doCreate ? (<NewLibraryItem onNewCreated={handleOnNewCreated} />) : (<></>)}
<NewLibraryItem onNewCreated={handleOnNewCreated} />
</Dialog>
);
}

View File

@ -1,4 +1,4 @@
import { TextareaAutosize } from "@material-ui/core";
import { Container, TextareaAutosize } from "@material-ui/core";
import { $textComposition } from "../lib/prompt";
import "./TextPrompt.css";
import { useStore } from "@nanostores/react";
@ -6,11 +6,11 @@ import { useStore } from "@nanostores/react";
export function TextPrompt() {
const text = useStore($textComposition);
return (
<>
<Container aria-label="text-area">
<TextareaAutosize
className="text-prompt"
defaultValue={text}
/>
</>
</Container>
)
}