This commit is contained in:
Jordan
2024-02-26 08:56:46 -08:00
parent 50bd3eaf5c
commit 48943a4b59
16 changed files with 2891 additions and 499 deletions

View File

@ -5,5 +5,6 @@ import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
// @ts-ignore
expect(linkElement).toBeInTheDocument();
});

View File

@ -0,0 +1,40 @@
import { expect, test } from 'jest-without-globals';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LibraryItem } from './LibraryItem';
import { Category, LibraryItem as LibItem } from '../lib/prompt';
import { randomUUID } from 'crypto';
const mockOnAddItem = jest.fn();
const mockItem: LibItem = {
id: randomUUID(),
name: 'Test Item',
category: Category.medium,
prompt: 'Test Prompt',
};
test('renders library item with add button', () => {
render(<LibraryItem item={mockItem} onAddItem={mockOnAddItem} />);
const addButton = screen.getByLabelText('Add');
const itemName = screen.getByText((content, element) => {
return content.includes(mockItem.name as string);
});
// @ts-ignore
expect(addButton).toBeInTheDocument();
// @ts-ignore
expect(itemName).toBeInTheDocument();
});
test('calls onAddItem when add button is clicked', async () => {
render(<LibraryItem item={mockItem} onAddItem={mockOnAddItem} />);
const addButton = screen.getByLabelText('Add');
userEvent.click(addButton);
expect(mockOnAddItem).toHaveBeenCalledWith(mockItem);
});

View File

@ -5,14 +5,14 @@ import { Button } from "@material-ui/core";
export interface StyleProps {
item: LibItem
onAddItem: (item: LibItem) => void;
onInsertItem: (item: LibItem) => void;
}
export function LibraryItem(props: StyleProps) {
const { item, onAddItem } = props
const { item, onInsertItem } = props
return (
<div>
<Button onClick={() => onAddItem(item)}>
<Button onClick={() => onInsertItem(item)} aria-label="Add">
<AddCircleOutlineOutlinedIcon/>
</Button>
<span>

View File

@ -5,20 +5,28 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp';
import {Composable} from "./IComposable"
import "./Nugget.css";
import { Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore } from '../lib/prompt';
export default function Nugget({ text, initialScore }: { text: string, initialScore?: number }) {
const [score, setScore] = useState(initialScore || 0);
export interface NuggetProps {
nugget : NuggetType,
}
export default function Nugget(props : NuggetProps) {
const {nugget} = props;
const scoreDisp = nugget.score > 0 ? "+" + nugget.score : nugget.score;
return (
<div className='nugget'>
<span className='text'>{text}</span>
<span className='text'>{nugget.item.name || nugget.item.prompt}</span>
<Divider orientation="vertical" variant="middle" flexItem />
<span className='score'>{score > 0 ? "+" + score : score}</span>
<span className='score'>{scoreDisp}</span>
<span className='buttons'>
<ButtonGroup size="small" orientation='vertical'>
<Button onClick={() => setScore(score + 1)} className='incScore'>
<Button onClick={() => increaseNuggetScore(nugget.id)} className='incScore'>
<KeyboardArrowUpIcon />
</Button>
<Button onClick={() => setScore(score - 1)} className='decScore'>
<Button onClick={() => decreaseNuggetScore(nugget.id)} className='decScore'>
<KeyboardArrowDownIcon />
</Button>
</ButtonGroup>

View File

@ -4,10 +4,16 @@ import "./Operation.css";
import { Op } from "../lib/operator";
import { randomUUID } from "crypto";
import { Composable } from "./IComposable";
import { Operation as OperationType, changeOperationOp } from "../lib/prompt";
import Nugget from "./Nugget";
interface OperationProps {
operation : OperationType
}
function Operation(props : OperationProps) {
const {operation} = props;
function Operation({ children, initialOp }: { children: ReactNode[], initialOp: Op }) {
const [op, setOp] = React.useState(initialOp);
const [contextMenu, setContextMenu] = React.useState<{
mouseX: number;
mouseY: number;
@ -35,19 +41,19 @@ function Operation({ children, initialOp }: { children: ReactNode[], initialOp:
};
const changeOperator = (opV: string) => {
setOp(opV as unknown as Op);
changeOperationOp(operation.id, opV as Op);
handleClose();
}
return (
<div className="operation" onContextMenu={handleContextMenu}>
<div className="title">{op}</div>
<div className="title">{operation.op}</div>
<div className="nuggets">
{Children.map(children, child => {
return (<div>
{child}
</div>)
})}
{
operation.items.map(nugget => {
return <Nugget nugget={nugget} />
})
}
</div>
<Menu
open={contextMenu !== null}

View File

@ -1,16 +1,19 @@
import { Button } from '@material-ui/core';
import { Children, ReactNode } from 'react';
import Masonry from '@mui/lab/Masonry';
import Nugget from './Nugget';
import { Operation } from './Operation';
import AddIcon from '@mui/icons-material/Add';
import "./PromptArea.css";
import { PromptLibrary } from './PromptLibrary';
import React from 'react';
import { $composition, $slottedComposition, LibraryItem, insertIntoComposition } from '../lib/prompt';
import { Category } from '@mui/icons-material';
import { useStore } from '@nanostores/react'
type Composable = (typeof Nugget) | (typeof Operation);
export default function PromptArea({ children }: { children?: any }) {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(false);
const handleClickOpen = () => {
setOpen(true);
@ -21,29 +24,27 @@ export default function PromptArea({ children }: { children?: any }) {
// setSelectedValue(value);
};
const handleComposableClicked = (composable : Composable) => {
setSelectedComposable(composable);
const handleOnInsertItem = (item: LibraryItem) => {
insertIntoComposition(item);
}
return (
<div>
<Button className="add-button">
<AddIcon />
<PromptLibrary
open={isPromptLibraryOpen}
onClose={handleClose}
></PromptLibrary>
const slottedComposition = useStore($slottedComposition);
</Button>
<div>
{
Children.map(children, child => {
return (<div>
{child as ReactNode}
</div>)
})
}
</div>
</div>
);
return (
<div>
<Button className="add-button">
<AddIcon />
<PromptLibrary
open={open}
onInsertItem={handleOnInsertItem}
></PromptLibrary>
</Button>
<Masonry columns={Object.keys(Category).length} spacing={2} sequential>
{slottedComposition.map(nugget => {
})}
</Masonry>
</div>
);
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PromptLibrary } from './PromptLibrary';
import { SimpleDialogProps } from './PromptLibrary';
import { Category, LibraryItem as LibItemType } from '../lib/prompt';
import { randomUUID } from 'crypto';
const mockOnAddItem = jest.fn();
const mockItem: LibItemType = {
id: randomUUID(),
name: 'Test Item',
category: Category.vibes,
prompt: 'Test Prompt',
};
const mockOnClose = jest.fn();
const mockOpen: boolean = true;
const mockProps: SimpleDialogProps = {
open: mockOpen,
onInsertItem: mockOnAddItem,
};
test('renders PromptLibrary with add button', () => {
render(<PromptLibrary {...mockProps} />);
const addButton = screen.getByLabelText('Add');
const itemName = screen.getByText((content, element) => {
return content.includes(mockItem.name as string);
});
// @ts-ignore
expect(addButton).toBeInTheDocument();
// @ts-ignore
expect(itemName).toBeInTheDocument();
});
test('calls onAddItem when add button is clicked', async () => {
render(<PromptLibrary {...mockProps} />);
const addButton = screen.getByLabelText('Add');
await userEvent.click(addButton);
expect(mockOnAddItem).toHaveBeenCalledWith(mockItem);
});

View File

@ -1,35 +1,66 @@
import { Dialog, DialogTitle } from "@material-ui/core";
import { Composable } from "./IComposable";
import { Library as PromptLibraryType, LibraryItem as LibItemType } from "../lib/prompt";
import { Checkbox, Dialog, DialogTitle } from "@material-ui/core";
import { LibraryItem as LibItemType, $library, Category } from "../lib/prompt";
import { LibraryItem } from "./LibraryItem";
import { ChangeEvent } from "react";
import { useStore } from "@nanostores/react";
export interface SimpleDialogProps {
open: boolean;
onClose: (composable: Composable) => void,
library: PromptLibraryType,
onAddItem : (item : LibItemType) => void,
// onClose: (composable: Composable) => void,
// onAddItem: (item: LibItemType) => void,
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 { onClose, library, open, onAddItem } = props;
const { open, onInsertItem } = props;
const handleClose = () => {
// onClose(selectedValue);
};
const library = useStore($library);
const handleNuggetClick = (composable : Composable) => {
onClose(composable);
};
const handleOnAddItem = (item: LibItemType) => {
// onAddItem(item);
}
const handleOnAddItem = (item : LibItemType) => {
const handleOnInsertItem = (item: LibItemType) => {
onInsertItem(item);
}
const filterCat = (catKey: string, catVal: string, v: ChangeEvent<HTMLInputElement>) => {
const isChecked = v.target.value === '1';
document.querySelectorAll(`.category-${catVal}`).forEach($el => {
if (isChecked) show($el)
else hide($el)
});
}
return (
<Dialog onClose={handleClose} open={open}>
<Dialog open={open}>
<DialogTitle>Prompt Library</DialogTitle>
<div className="categories">
{Object.entries(Category).map(([catKey, catVal]) => {
return (
<div>
<Checkbox onChange={v => filterCat(catKey, catVal, v)} />
<span>{title(catVal)}</span>
</div>
)
})}
</div>
<div>
{
library.map(item => <LibraryItem item={item} onAddItem={handleOnAddItem} />)
library?.map(item => <LibraryItem item={item} onInsertItem={handleOnInsertItem} />)
}
</div>
</Dialog>

106
src/lib/prompt.test.ts Normal file
View File

@ -0,0 +1,106 @@
import { randomUUID } from "crypto";
import { Op } from "./operator";
import {
Library as LibraryType,
Composition as CompositionType,
$library,
addItemToLibrary,
removeItemFromLibrary,
$composition,
insertIntoComposition,
Category,
removeFromComposition,
increaseNuggetScore,
decreaseNuggetScore, changeOperationOp, nuggetToText, operationToText, textComposition,
$slottedComposition,
Operation,
Nugget,
} from "./prompt";
const mockLibrary: LibraryType = [
{ id: randomUUID(), name: "Name1", prompt: "Prompt1", category: Category.subject },
{ id: randomUUID(), name: "Name2", prompt: "Prompt2", category: Category.style },
{ id: randomUUID(), name: "Name3", prompt: "Prompt3", category: Category.vibes },
{ id: randomUUID(), name: "Name4", prompt: "Prompt4", category: Category.medium },
];
const mockComposition: CompositionType = [
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
{ id: randomUUID(), item: mockLibrary[2], score: 0 },
{ id: randomUUID(), item: mockLibrary[3], score: 0 },
{
id: randomUUID(), op: Op.AND, items: [
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
]
},
];
beforeAll(() => {
mockLibrary.forEach(item => {
addItemToLibrary(item);
insertIntoComposition(item);
});
});
test("addItemToLibrary", () => {
addItemToLibrary({ id: randomUUID(), name: "Name5", prompt: "Prompt5", category: Category.subject });
expect($library.get().length).toBe(5);
});
test("removeItemFromLibrary", () => {
removeItemFromLibrary(mockLibrary[0]);
expect($library.get().length).toBe(3);
});
test("insertIntoComposition", () => {
insertIntoComposition(mockLibrary[3]);
expect($composition.get().length).toBe(5);
});
test("removeFromComposition", () => {
removeFromComposition(mockComposition[0]);
expect($composition.get().length).toBe(4);
});
test("increaseNuggetScore", () => {
increaseNuggetScore(mockComposition[0].id, 2);
expect((mockComposition[0] as Nugget).score).toBe(2);
});
test("decreaseNuggetScore", () => {
decreaseNuggetScore(mockComposition[0].id, 2);
expect((mockComposition[0] as Nugget).score).toBe(-2);
});
test("changeOperationOp", () => {
changeOperationOp(mockComposition[4].id, Op.AND);
expect((mockComposition[4] as Operation).op).toBe(Op.AND);
});
test("nuggetToText", () => {
expect(nuggetToText({ id: randomUUID(), item: mockLibrary[0], score: 0 })).toBe("(Prompt1)");
});
test("operationToText", () => {
expect(operationToText({
id: randomUUID(), op: Op.AND, items: [
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
]
})).toBe("(Prompt1, Prompt2).concat()");
});
test("textComposition", () => {
expect(textComposition).toBe("(Prompt1)(Prompt2)(Prompt3)(Prompt4)(Prompt1, Prompt2).concat()");
});
test("$slottedComposition", () => {
expect($slottedComposition).toEqual([
[mockComposition[0], mockComposition[1], mockComposition[2], mockComposition[3]],
[],
[],
[],
]);
});

View File

@ -1,113 +0,0 @@
import { nSQL } from "@nano-sql/core";
import { Op } from "./operator";
import { RocksDB } from "@nano-sql/adapter-rocksdb";
import tables from "./schema.json";
import { uuid } from "@nano-sql/core/lib/utilities";
type Id = string;
type IdAble = {
id: Id,
}
export enum Category {
subject = "subject",
style = "style",
vibes = "vibes",
medium = "medium",
}
export type LibraryItem = {
id: Id,
name?: string,
prompt: string,
category: Category,
}
export type Library = Array<LibraryItem>;
export type Nugget = IdAble & {
item: LibraryItem,
score: number,
}
export type Operation = IdAble & {
op: Op,
items: Array<Nugget>
}
export type PromptItem = Operation | Nugget
export type PromptArea = Array<PromptItem>;
export async function getDb() {
return new Promise((resolve, reject) => {
// typical setup
nSQL().createDatabase({
id: "my_db", // can be anything that's a string
mode: new RocksDB(),
tables: [
{
"name": "library_item",
"model": {
"id:uuid": { "pk": true },
"name:string": {},
"prompt:string": { notNull: true },
"category:string": { notNull: true, }
}
},
{
"name": "nugget",
"model": {
"id:uuid": { "pk": true },
"library_item:uuid": {},
"score:number": {}
}
},
{
"name": "operation",
"model": {
"id:uuid": { pk: true },
"op:string": {},
"items:array": {}
}
},
{
"name": "library",
"model": {
"item:uuid": {},
}
},
{
"name": "prompt_area",
"model": {
"type:string": {notNull: true},
"item:obj": {notNull: true},
}
}
],
version: 1, // current schema/database version
onVersionUpdate: (prevVersion) => { // migrate versions
return new Promise((res, rej) => {
switch (prevVersion) {
case 1:
// migrate v1 to v2
res(2);
break;
case 2:
// migrate v2 to v3
res(3);
break;
}
});
}
}).then((db) => {
resolve(db)
}).catch((err) => {
reject(err);
})
});
}

138
src/lib/prompt.tsx Normal file
View File

@ -0,0 +1,138 @@
import { randomUUID } from "crypto";
import { Op } from "./operator";
import { atom, computed } from "nanostores";
type Id = string;
type IdAble = {
id: Id,
}
export enum Category {
subject = "subject",
style = "style",
vibes = "vibes",
medium = "medium",
}
const N_CATEGORIES = Object.keys(Category).length;
export function catI(c: Category | string): number {
return Object.keys(Category).indexOf(c);
}
export type LibraryItem = {
id: Id,
name?: string,
prompt: string,
category: Category,
}
export type Library = Array<LibraryItem>;
export type Nugget = IdAble & {
item: LibraryItem,
score: number,
}
export type Operation = IdAble & {
op: Op,
items: Array<Nugget>
}
export type PromptItem = Operation | Nugget
export type Composition = Array<PromptItem>;
export const $library = atom<Library>([])
export function addItemToLibrary(item: LibraryItem) {
$library.set([
...$library.get(), item,
]);
}
export function removeItemFromLibrary(item: LibraryItem) {
$library.set($library.get().filter(i => i.id != item.id));
}
export const $composition = atom<Composition>([])
export function insertIntoComposition(item: LibraryItem) {
$composition.set([
...$composition.get(),
{ id: randomUUID(), item, score: 0 } as Nugget,
]);
}
export function removeFromComposition(item: PromptItem) {
$composition.set([
...$composition.get().filter(i => i.id === item.id)
]);
}
export function increaseNuggetScore(nuggetId: Id, amount: number = 1) {
$composition.set([
...$composition.get().map(item => {
return (item.id == nuggetId && "score" in item) ? { ...item, score: item.score + amount } : item;
}
),
]);
}
export function decreaseNuggetScore(nuggetId: Id, amount: number = 1) {
$composition.set([
...$composition.get().map(item => {
return (item.id == nuggetId && "score" in item) ? { ...item, score: item.score - amount } : item;
}
),
]);
}
export function changeOperationOp(operationId: Id, op: Op) {
$composition.set([
...$composition.get().map(item => {
return (item.id == operationId) ? { ...item, op: op } : item;
}
),
]);
}
export function nuggetToText(nugget: Nugget) {
const sign = (nugget.score > 0 ? '+' : (nugget.score < 0 ? '-' : ''))
return "(" + nugget.item.prompt + ")" + (new Array(nugget.score)).map(i => sign).join("");
}
export function operationToText(operation: Operation): string {
return "(" + operation.items.map(nuggetToText).join(", ") + ")." + operation.op + "()";
}
export const textComposition = computed($composition, (composition) => {
const JOINER = ", ";
composition.map(item => {
return "op" in item ? operationToText(item) : nuggetToText(item);
}).join(JOINER);
});
export type SlottedComposition = PromptItem[][];
/**
* This is necessary since a "composition" needs to have prompt items in
* different columns.
*
* There are n columns (or slots) where n is the number of categories.
*/
export const $slottedComposition = computed($composition, (composition) => {
const slotted = new Array(N_CATEGORIES) as SlottedComposition;
composition.forEach(nugget => {
if ("op" in nugget) {
if (!nugget.items.length)
return null;
const cat = nugget.items[0].item.category;
slotted[catI(cat)].push(nugget);
} else {
const cat = nugget.item.category;
slotted[catI(cat)].push(nugget);
}
})
return slotted;
});

View File

@ -2,4 +2,6 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// import '@testing-library/jest-dom';
// import '@testing-library/jest-dom/vitest';
// import '@testing-library/jest-dom';

View File