diff --git a/database/factories/CardFactory.php b/database/factories/CardFactory.php
index a6bd416..7dacfdc 100644
--- a/database/factories/CardFactory.php
+++ b/database/factories/CardFactory.php
@@ -9,6 +9,14 @@
*/
class CardFactory extends Factory
{
+ protected function createTextBlock(string $text): array
+ {
+ return [
+ 'type' => 'text',
+ 'content' => $text,
+ ];
+ }
+
/**
* Define the model's default state.
*
@@ -17,7 +25,13 @@ class CardFactory extends Factory
public function definition(): array
{
return [
- //
+ 'front' => [
+ $this->createTextBlock($this->faker->sentence),
+ ],
+ 'back' => [
+ $this->createTextBlock($this->faker->sentence),
+ ],
+
];
}
}
diff --git a/package-lock.json b/package-lock.json
index 6c459e2..1157e5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
"pinia": "^2.1.7",
"quill": "^2.0.2",
"quill-paste-smart": "^2.0.0",
- "radix-vue": "^1.8.4",
+ "radix-vue": "^1.9.12",
"ramda": "^0.30.1",
"uuid": "^10.0.0",
"vue": "^3.4.21",
@@ -7418,9 +7418,9 @@
}
},
"node_modules/radix-vue": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.3.tgz",
- "integrity": "sha512-9pewcgzghM+B+FO1h9mMsZa/csVH6hElpN1sqmG4/qoeieiDG0i4nhMjS7p2UOz11EEdVm7eLandHSPyx7hYhg==",
+ "version": "1.9.12",
+ "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.12.tgz",
+ "integrity": "sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.7",
diff --git a/package.json b/package.json
index d237007..154e6b1 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"pinia": "^2.1.7",
"quill": "^2.0.2",
"quill-paste-smart": "^2.0.0",
- "radix-vue": "^1.8.4",
+ "radix-vue": "^1.9.12",
"ramda": "^0.30.1",
"uuid": "^10.0.0",
"vue": "^3.4.21",
diff --git a/resources/client/components/DeckMembership.vue b/resources/client/components/DeckMembership.vue
index 5ff9626..8f9e04d 100644
--- a/resources/client/components/DeckMembership.vue
+++ b/resources/client/components/DeckMembership.vue
@@ -12,7 +12,8 @@
@@ -24,8 +25,8 @@
- Viewer
- Editor
+ Viewer
+ Editor
diff --git a/resources/client/components/icons/IconSearch.vue b/resources/client/components/icons/IconSearch.vue
new file mode 100644
index 0000000..0ad090f
--- /dev/null
+++ b/resources/client/components/icons/IconSearch.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/resources/client/components/icons/index.ts b/resources/client/components/icons/index.ts
index 2fc33a6..61e9bf7 100644
--- a/resources/client/components/icons/index.ts
+++ b/resources/client/components/icons/index.ts
@@ -27,6 +27,7 @@ export { default as IconPencil } from "./IconPencil.vue";
export { default as IconPlusFilled } from "./IconPlusFilled.vue";
export { default as IconUser } from "./IconUser.vue";
export { default as IconRefresh } from "./IconRefresh.vue";
+export { default as IconSearch } from "./IconSearch.vue";
export { default as IconSettings } from "./IconSettings.vue";
export { default as IconSpinner } from "./IconSpinner.vue";
export { default as IconSound } from "./IconSound.vue";
diff --git a/resources/client/components/ui/button/index.ts b/resources/client/components/ui/button/index.ts
index f02ad70..430fce9 100644
--- a/resources/client/components/ui/button/index.ts
+++ b/resources/client/components/ui/button/index.ts
@@ -8,13 +8,13 @@ export const buttonVariants = cva(
variants: {
variant: {
default:
- "bg-brand-maroon-800 text-neutral-50 shadow hover:bg-brand-maroon-800/90 dark:bg-neutral-50 dark:text-brand-maroon-800 dark:hover:bg-neutral-50/90",
+ "bg-brand-maroon-800 text-neutral-50 hover:bg-brand-maroon-800/90 dark:bg-neutral-50 dark:text-brand-maroon-800 dark:hover:bg-neutral-50/90",
destructive:
- "bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
+ "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
outline:
"border border-brand-maroon-800/20 hover:bg-white brand-maroon-800/20 hover:text-brand-maroon-800 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
secondary:
- "bg-brand-maroon-800/5 text-brand-maroon-800 shadow-sm hover:bg-brand-maroon-800/80 hover:text-white dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
+ "bg-brand-maroon-800/5 text-brand-maroon-800 hover:bg-brand-maroon-800/80 hover:text-white dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
ghost:
"hover:bg-neutral-100 hover:text-brand-maroon-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
link: "text-brand-maroon-800 underline-offset-4 hover:underline dark:text-neutral-50",
diff --git a/resources/client/components/ui/dialog/DialogContent.vue b/resources/client/components/ui/dialog/DialogContent.vue
index fb6406f..94a7ede 100644
--- a/resources/client/components/ui/dialog/DialogContent.vue
+++ b/resources/client/components/ui/dialog/DialogContent.vue
@@ -33,6 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
/>
Close
diff --git a/resources/client/components/ui/dropdown-menu/DropdownMenu.vue b/resources/client/components/ui/dropdown-menu/DropdownMenu.vue
index b83d90b..6fc03a6 100644
--- a/resources/client/components/ui/dropdown-menu/DropdownMenu.vue
+++ b/resources/client/components/ui/dropdown-menu/DropdownMenu.vue
@@ -1,10 +1,15 @@
diff --git a/resources/client/layouts/AuthenticatedLayout/AuthenticatedLayout.vue b/resources/client/layouts/AuthenticatedLayout/AuthenticatedLayout.vue
index c8943c0..17cf07a 100644
--- a/resources/client/layouts/AuthenticatedLayout/AuthenticatedLayout.vue
+++ b/resources/client/layouts/AuthenticatedLayout/AuthenticatedLayout.vue
@@ -69,13 +69,17 @@ const { data: decks } = useAllDecksQuery();
const myDecks = computed((): T.Deck[] => {
return (
- decks.value?.filter((deck) => deck.current_user_role === "owner") ?? []
+ decks.value?.filter(
+ (deck) => deck.current_user_role === T.MembershipRole.OWNER,
+ ) ?? []
);
});
const sharedDecks = computed((): T.Deck[] => {
return (
- decks.value?.filter((deck) => deck.current_user_role !== "owner") ?? []
+ decks.value?.filter(
+ (deck) => deck.current_user_role !== T.MembershipRole.OWNER,
+ ) ?? []
);
});
diff --git a/resources/client/pages/Decks/DeckIndexPage/DecksIndexPage.vue b/resources/client/pages/Decks/DeckIndexPage/DecksIndexPage.vue
index 4cae01a..a826817 100644
--- a/resources/client/pages/Decks/DeckIndexPage/DecksIndexPage.vue
+++ b/resources/client/pages/Decks/DeckIndexPage/DecksIndexPage.vue
@@ -49,13 +49,17 @@ const { data: decks } = useAllDecksQuery();
const myDecks = computed((): T.Deck[] => {
return (
- decks.value?.filter((deck) => deck.current_user_role === "owner") ?? []
+ decks.value?.filter(
+ (deck) => deck.current_user_role === T.MembershipRole.OWNER,
+ ) ?? []
);
});
const sharedDecks = computed((): T.Deck[] => {
return (
- decks.value?.filter((deck) => deck.current_user_role !== "owner") ?? []
+ decks.value?.filter(
+ (deck) => deck.current_user_role !== T.MembershipRole.OWNER,
+ ) ?? []
);
});
diff --git a/resources/client/pages/Decks/DeckShowPage/DeckShowPage.vue b/resources/client/pages/Decks/DeckShowPage/DeckShowPage.vue
index c7c0f20..c9871a2 100644
--- a/resources/client/pages/Decks/DeckShowPage/DeckShowPage.vue
+++ b/resources/client/pages/Decks/DeckShowPage/DeckShowPage.vue
@@ -67,7 +67,23 @@
class="flex justify-between items-baseline sticky top-16 lg:top-0 z-10 bg-brand-oatmeal-100 py-4"
>
Cards
-
+
+
+
+
Create Card
-
+
();
+const cardSearch = ref("");
const deckIdRef = computed(() => props.deckId);
const canEdit = computed(() => {
@@ -174,6 +193,23 @@ function handleDeleteCard(card: T.Card) {
const initialCardSide = ref("front");
+function doesBlockContainText(block: T.ContentBlock, text: string) {
+ if (block.type !== "text") return false;
+ return (block as T.TextContentBlock).content
+ .toLowerCase()
+ .includes(text.toLowerCase());
+}
+
+const filteredCards = computed((): T.Card[] => {
+ if (!deck.value?.cards) return [];
+
+ return deck.value.cards.filter((card) => {
+ return [...card.front, ...card.back].some((block) => {
+ return doesBlockContainText(block, cardSearch.value);
+ });
+ });
+});
+
function flipAllCards() {
initialCardSide.value = initialCardSide.value === "front" ? "back" : "front";
}
diff --git a/resources/client/pages/Decks/DeckShowPage/MoreCardActions.vue b/resources/client/pages/Decks/DeckShowPage/MoreCardActions.vue
index 3327409..221cc9c 100644
--- a/resources/client/pages/Decks/DeckShowPage/MoreCardActions.vue
+++ b/resources/client/pages/Decks/DeckShowPage/MoreCardActions.vue
@@ -1,9 +1,9 @@
-
diff --git a/resources/client/types/index.ts b/resources/client/types/index.ts
index ffee5e2..3e15363 100644
--- a/resources/client/types/index.ts
+++ b/resources/client/types/index.ts
@@ -35,7 +35,11 @@ export interface User {
};
}
-type MembershipRole = "viewer" | "editor" | "owner";
+export enum MembershipRole {
+ VIEWER = "viewer",
+ EDITOR = "editor",
+ OWNER = "owner",
+}
export interface DeckMembership {
id: number;
diff --git a/tests/cypress/e2e/deckShowPage.cy.ts b/tests/cypress/e2e/deckShowPage.cy.ts
new file mode 100644
index 0000000..2439aed
--- /dev/null
+++ b/tests/cypress/e2e/deckShowPage.cy.ts
@@ -0,0 +1,125 @@
+import * as T from "../../../resources/client/types";
+
+describe("DeckShowPage", () => {
+ let deckId = null;
+ beforeEach(() => {
+ cy.refreshDatabase();
+ cy.login({ umndid: "user" });
+
+ cy.createDeckForUser("user", { name: "Deck 1" }).then((deck) => {
+ deckId = deck.id;
+ });
+ });
+
+ it("creates a card", () => {
+ cy.visit(`/decks/${deckId}`);
+ // create a card
+ cy.contains("Create Card").click();
+
+ // add front side content
+ cy.get('[data-cy="front-side-input"]').within(() => {
+ cy.get(
+ '[data-cy="text-block-input-container"] [data-cy="text-block-input"] .ql-editor',
+ ).type("Front side");
+ });
+
+ // add back side content
+ cy.get('[data-cy="back-side-input"]').within(() => {
+ cy.get(
+ '[data-cy="text-block-input-container"] [data-cy="text-block-input"] .ql-editor',
+ ).type("Back side");
+ });
+
+ // save the card
+ cy.get('[data-cy="save-card-button"]').click();
+
+ // we should be on the deck page
+ cy.contains("Deck 1");
+
+ // we should see the card
+ cy.get('[data-cy="flippable-card"]').within(() => {
+ cy.contains("Front side");
+ cy.contains("Flip").click();
+
+ cy.contains("Back side");
+ });
+ });
+
+ it("edits a card", () => {
+ // create a card
+ cy.createTextCardInDeck(deckId, { front: "Front side", back: "Back side" });
+
+ cy.visit(`/decks/${deckId}`);
+
+ cy.contains("Front side")
+ .closest('[data-cy="card-side-view--Front"]')
+ .within(() => {
+ cy.get('[data-cy="more-card-actions-button"]').click();
+ });
+
+ cy.contains("Edit Card").click();
+ // edit the front side
+ cy.get('[data-cy="front-side-input"]').within(() => {
+ cy.get(
+ '[data-cy="text-block-input-container"] [data-cy="text-block-input"] .ql-editor',
+ ).type(" edited");
+ });
+
+ // save the card
+ cy.get('[data-cy="save-card-button"]').click();
+
+ // we should be on the deck page
+ cy.contains("Deck 1");
+
+ // we should see the card
+ cy.get('[data-cy="flippable-card"]').within(() => {
+ cy.contains("Front side edited");
+ cy.contains("Flip").click();
+
+ cy.contains("Back side");
+ });
+ });
+
+ it("deletes a card", () => {
+ cy.createTextCardInDeck(deckId, { front: "Front side", back: "Back side" });
+
+ cy.visit(`/decks/${deckId}`);
+
+ cy.contains("Front side")
+ .closest('[data-cy="card-side-view--Front"]')
+ .within(() => {
+ cy.get('[data-cy="more-card-actions-button"]').click();
+ });
+
+ cy.contains("Delete Card").click();
+
+ // confirm the deletion
+ cy.get('[data-cy="dialog-content"]').within(() => {
+ // expect a confirm modal
+ cy.contains("Are you sure").should("be.visible");
+ cy.get('button[type="submit"]').should("have.text", "Delete").click();
+ });
+
+ cy.contains("Front side").should("not.be.visible");
+ });
+
+ it("filters the list of cards given a search term", () => {
+ cy.createTextCardInDeck(deckId, { front: "Front side", back: "Back side" });
+ cy.createTextCardInDeck(deckId, {
+ front: "Another card",
+ back: "Back side",
+ });
+
+ cy.visit(`/decks/${deckId}`);
+
+ // verify that we see all the cards
+ cy.get('[data-cy="flippable-card"]').should("have.length", 2);
+
+ // search for a card
+ cy.get('[data-cy="card-search-input"]').type("Another");
+
+ // verify that we see only the card that matches the search term
+ cy.get('[data-cy="flippable-card"]').should("have.length", 1);
+ cy.contains("Another card");
+ });
+});
diff --git a/tests/cypress/e2e/happyPath.cy.ts b/tests/cypress/e2e/happyPath.cy.ts
index 90a440a..ce78d86 100644
--- a/tests/cypress/e2e/happyPath.cy.ts
+++ b/tests/cypress/e2e/happyPath.cy.ts
@@ -17,7 +17,7 @@ describe("Happy Path", () => {
cy.contains("Decks");
});
- it.only("creates a deck", () => {
+ it("creates a deck", () => {
cy.login({ umndid: "admin" });
cy.visit("/decks");
diff --git a/tests/cypress/support/commands.ts b/tests/cypress/support/commands.ts
index 698b01a..c8ebc9b 100644
--- a/tests/cypress/support/commands.ts
+++ b/tests/cypress/support/commands.ts
@@ -34,4 +34,60 @@
// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
// }
// }
-// }
\ No newline at end of file
+// }
+
+import * as T from "../../../resources/client/types";
+
+Cypress.Commands.add("getUserByUsername", (umndid: string) => {
+ return cy.php(
+ `App\\Models\\User::where('umndid', '${umndid}')->firstOrFail()`,
+ );
+});
+
+Cypress.Commands.add("getUser", (userId: number) => {
+ return cy.php(`App\\Models\\User::findOrFail(${userId});`);
+});
+
+Cypress.Commands.add(
+ "createDeckForUser",
+ (
+ umndid: string,
+ { name, description = "" }: { name: string; description: string },
+ ) => {
+ return cy.php(`
+ $user = \\App\\Models\\User::where("umndid", "${umndid}")->first();
+ $deck = \\App\\Models\\Deck::factory()->create([
+ 'name' => '${name}',
+ 'description' => '${description}',
+ ]);
+ $user->decks()->attach($deck, ["role" => "${T.MembershipRole.OWNER}"]);
+
+ return $deck;
+ `);
+ },
+);
+
+Cypress.Commands.add(
+ "createTextCardInDeck",
+ (deckId: number, { front, back }: { front: string; back: string }) => {
+ return cy.create("App\\Models\\Card", 1, {
+ deck_id: deckId,
+ front: [
+ {
+ id: crypto.randomUUID(),
+ type: "text",
+ content: front,
+ meta: null,
+ },
+ ],
+ back: [
+ {
+ id: crypto.randomUUID(),
+ type: "text",
+ content: back,
+ meta: null,
+ },
+ ],
+ });
+ },
+);
diff --git a/tests/cypress/support/index.d.ts b/tests/cypress/support/index.d.ts
index eb52d46..1edd876 100644
--- a/tests/cypress/support/index.d.ts
+++ b/tests/cypress/support/index.d.ts
@@ -1,92 +1,138 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
///
declare namespace Cypress {
- interface Chainable {
- /**
- * Log in the user with the given attributes, or create a new user and then log them in.
- *
- * @example
- * cy.login()
- * cy.login({ id: 1 })
- */
- login(attributes?: object): Chainable;
+ interface Chainable {
+ /**
+ * Log in the user with the given attributes, or create a new user and then log them in.
+ *
+ * @example
+ * cy.login()
+ * cy.login({ id: 1 })
+ */
+ login(umndidOrAttributes: string | object): Chainable;
- /**
- * Log out the current user.
- *
- * @example
- * cy.logout()
- */
- logout(): Chainable;
+ /**
+ * Log out the current user.
+ *
+ * @example
+ * cy.logout()
+ */
+ logout(): Chainable;
- /**
- * Fetch the currently authenticated user.
- *
- * @example
- * cy.currentUser()
- */
- currentUser(): Chainable;
+ /**
+ * Fetch the currently authenticated user.
+ *
+ * @example
+ * cy.currentUser()
+ */
+ currentUser(): Chainable;
- /**
- * Fetch a CSRF token from the server.
- *
- * @example
- * cy.logout()
- */
- csrfToken(): Chainable;
+ /**
+ * Fetch a CSRF token from the server.
+ *
+ * @example
+ * cy.logout()
+ */
+ csrfToken(): Chainable;
- /**
- * Fetch a fresh list of URI routes from the server.
- *
- * @example
- * cy.logout()
- */
- refreshRoutes(): Chainable;
+ /**
+ * Fetch a fresh list of URI routes from the server.
+ *
+ * @example
+ * cy.logout()
+ */
+ refreshRoutes(): Chainable;
- /**
- * Create and persist a new Eloquent record using Laravel model factories.
- *
- * @example
- * cy.create('App\\User');
- * cy.create('App\\User', 2);
- * cy.create('App\\User', 2, { active: false });
- * cy.create({ model: 'App\\User', state: ['guest'], relations: ['profile'], count: 2 }
- */
- create(): Chainable;
+ /**
+ * Create and persist a new Eloquent record using Laravel model factories.
+ *
+ * @example
+ * cy.create('App\\User');
+ * cy.create('App\\User', 2);
+ * cy.create('App\\User', 2, { active: false });
+ * cy.create({ model: 'App\\User', state: ['guest'], relations: ['profile'], count: 2 }
+ */
+ create(model: string): Chainable;
+ create(model: string, count: number): Chainable;
+ create(model: string, count: number, props: object): Chainable;
+ create(options: {
+ model: string;
+ state?: string[];
+ load?: string[];
+ count?: number;
+ attributes?: object;
+ });
- /**
- * Refresh the database state using Laravel's migrate:fresh command.
- *
- * @example
- * cy.refreshDatabase()
- * cy.refreshDatabase({ '--drop-views': true }
- */
- refreshDatabase(options?: object): Chainable;
+ /**
+ * Refresh the database state using Laravel's migrate:fresh command.
+ *
+ * @example
+ * cy.refreshDatabase()
+ * cy.refreshDatabase({ '--drop-views': true }
+ */
+ refreshDatabase(options?: object): Chainable;
- /**
- * Run Artisan's db:seed command.
- *
- * @example
- * cy.seed()
- * cy.seed('PlansTableSeeder')
- */
- seed(seederClass?: string): Chainable;
+ /**
+ * Run Artisan's db:seed command.
+ *
+ * @example
+ * cy.seed()
+ * cy.seed('PlansTableSeeder')
+ */
+ seed(seederClass?: string): Chainable;
- /**
- * Run an Artisan command.
- *
- * @example
- * cy.artisan()
- */
- artisan(command: string, parameters?: object, options?: object): Chainable;
+ /**
+ * Run an Artisan command.
+ *
+ * @example
+ * cy.artisan()
+ */
+ artisan(
+ command: string,
+ parameters?: object,
+ options?: object,
+ ): Chainable;
- /**
- * Execute arbitrary PHP on the server.
- *
- * @example
- * cy.php('2 + 2')
- * cy.php('App\\User::count()')
- */
- php(command: string): Chainable;
- }
+ /**
+ * Execute arbitrary PHP on the server.
+ *
+ * @example
+ * cy.php('2 + 2')
+ * cy.php('App\\User::count()')
+ */
+ php(command: string): Chainable;
+
+ /**
+ * Get a user by their userId.
+ */
+ getUser(userId: number): Chainable;
+
+ /**
+ * Get a user by their username (umndid).
+ */
+ getUserByUsername(umndid: string): Chainable;
+
+ /**
+ * Create a deck for a user.
+ */
+ createDeckForUser(
+ umndid: string,
+ {
+ name,
+ description,
+ }: {
+ name: string;
+ description?: string;
+ },
+ ): Chainable;
+
+ /**
+ * Create a text card in a deck.
+ */
+ createTextCardInDeck(
+ deckId: number,
+ { front, back }: { front: string; back: string },
+ ): Chainable;
+ }
}
diff --git a/tests/cypress/support/index.ts b/tests/cypress/support/index.ts
index 21ee295..2e45904 100644
--- a/tests/cypress/support/index.ts
+++ b/tests/cypress/support/index.ts
@@ -18,6 +18,7 @@
import "./laravel-commands";
import "./laravel-routes";
import "./assertions";
+import "./commands";
before(() => {
cy.artisan("config:clear", {}, { log: false });
diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json
index bc7bf61..d02e74e 100644
--- a/tests/cypress/tsconfig.json
+++ b/tests/cypress/tsconfig.json
@@ -6,7 +6,7 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"paths": {
- "@/*": ["../../resources/js/*"]
+ "@/*": ["../../resources/client/*"]
}
},
"include": ["**/*.ts"]