summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json193
-rw-r--r--package.json2
-rw-r--r--ui/lib/components/ActiveChannel.svelte6
-rw-r--r--ui/lib/components/ChangePassword.svelte24
-rw-r--r--ui/lib/components/CreateChannelForm.svelte15
-rw-r--r--ui/lib/components/Invites.svelte8
-rw-r--r--ui/lib/components/LogIn.svelte27
-rw-r--r--ui/lib/components/LogOut.svelte10
-rw-r--r--ui/lib/components/Message.svelte7
-rw-r--r--ui/lib/components/MessageInput.svelte25
-rw-r--r--ui/lib/components/MessageRun.svelte10
-rw-r--r--ui/routes/(app)/+layout.svelte16
-rw-r--r--ui/routes/(app)/ch/[channel]/+page.svelte28
-rw-r--r--ui/routes/(app)/me/+page.svelte31
-rw-r--r--ui/routes/(login)/invite/[invite]/+page.svelte18
-rw-r--r--ui/routes/(login)/login/+page.svelte18
-rw-r--r--ui/routes/(login)/setup/+page.svelte18
-rw-r--r--ui/tests/lib/components/ActiveChannel.svelte.test.js22
-rw-r--r--ui/tests/lib/components/ChangePassword.svelte.test.js90
-rw-r--r--ui/tests/lib/components/Channel.svelte.test.js22
-rw-r--r--ui/tests/lib/components/ChannelList.svelte.test.js22
-rw-r--r--ui/tests/lib/components/CreateChannelForm.svelte.test.js65
-rw-r--r--ui/tests/lib/components/LogIn.svelte.test.js38
-rw-r--r--ui/tests/lib/components/MessageInput.svelte.test.js61
-rw-r--r--ui/tests/lib/components/MessageRun.svelte.test.js22
-rw-r--r--vite.config.js6
26 files changed, 463 insertions, 341 deletions
diff --git a/package-lock.json b/package-lock.json
index a170278..dd5dcdf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,8 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
+ "@testing-library/svelte": "^5.2.7",
+ "@testing-library/user-event": "^14.6.1",
"@types/eslint": "^9.6.1",
"@vitest/coverage-v8": "^3.0.6",
"autoprefixer": "^10.4.20",
@@ -67,6 +69,21 @@
"lru-cache": "^10.4.3"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
@@ -103,6 +120,19 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.26.9",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
+ "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
@@ -1320,6 +1350,83 @@
"vite": "^6.0.0"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/svelte": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.7.tgz",
+ "integrity": "sha512-aGhUaFmEXEVost4QOsbHUUbHLwi7ZZRRxAHFDO2Cmr0BZD3/3+XvaYEPq70Rdw0NRNjdqZHdARBEcrCOkPuAqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@testing-library/dom": "^10.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
+ "vite": "*",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -2011,6 +2118,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
@@ -2018,6 +2135,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
@@ -3015,6 +3139,13 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3194,6 +3325,16 @@
"node": ">=12"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -3689,6 +3830,44 @@
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3705,6 +3884,20 @@
"node": ">=6"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
diff --git a/package.json b/package.json
index f4d78ac..a43e01e 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
+ "@testing-library/svelte": "^5.2.7",
+ "@testing-library/user-event": "^14.6.1",
"@types/eslint": "^9.6.1",
"@vitest/coverage-v8": "^3.0.6",
"autoprefixer": "^10.4.20",
diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte
index 9c181e4..f7837aa 100644
--- a/ui/lib/components/ActiveChannel.svelte
+++ b/ui/lib/components/ActiveChannel.svelte
@@ -1,9 +1,9 @@
<script>
import MessageRun from './MessageRun.svelte';
- let { messageRuns } = $props();
+ let { messageRuns, deleteMessage = async (id) => {} } = $props();
</script>
-{#each messageRuns as { sender, messages }}
- <MessageRun {sender} {messages} />
+{#each messageRuns as { sender, ownMessage, messages }}
+ <MessageRun {sender} {ownMessage} {messages} {deleteMessage} />
{/each}
diff --git a/ui/lib/components/ChangePassword.svelte b/ui/lib/components/ChangePassword.svelte
index bf94ea7..742d4f1 100644
--- a/ui/lib/components/ChangePassword.svelte
+++ b/ui/lib/components/ChangePassword.svelte
@@ -1,28 +1,26 @@
<script>
- import { changePassword } from '$lib/apiServer.js';
+ let { changePassword = async (currentPassword, newPassword) => {} } = $props();
- let currentPassword = $state(''),
- newPassword = $state(''),
- confirmPassword = $state(''),
- pending = $state(false),
- form;
+ let currentPassword = $state('');
+ let newPassword = $state('');
+ let confirmPassword = $state('');
+ let pending = $state(false);
let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword);
let disabled = $derived(pending || !valid);
async function onsubmit(event) {
event.preventDefault();
pending = true;
- let response = await changePassword(currentPassword, newPassword);
- switch (response.status) {
- case 200:
- form.reset();
- break;
+ try {
+ await changePassword(currentPassword, newPassword);
+ event.target.reset();
+ } finally {
+ pending = false;
}
- pending = false;
}
</script>
-<form class="form" {onsubmit} bind:this={form}>
+<form class="form" {onsubmit}>
<label
>current password
<input
diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateChannelForm.svelte
index 85c85bb..471c2b7 100644
--- a/ui/lib/components/CreateChannelForm.svelte
+++ b/ui/lib/components/CreateChannelForm.svelte
@@ -1,21 +1,22 @@
<script>
- import { createChannel } from '$lib/apiServer';
+ let { createChannel = async (name) => {} } = $props();
let name = $state('');
let disabled = $state(false);
- async function handleSubmit(event) {
+ async function onsubmit(event) {
event.preventDefault();
disabled = true;
- const response = await createChannel(name);
- if (200 <= response.status && response.status < 300) {
- name = '';
+ try {
+ await createChannel(name);
+ event.target.reset();
+ } finally {
+ disabled = false;
}
- disabled = false;
}
</script>
-<form onsubmit={handleSubmit}>
+<form {onsubmit}>
<input type="text" placeholder="create channel" bind:value={name} {disabled} />
<button type="submit">&#x2795;</button>
</form>
diff --git a/ui/lib/components/Invites.svelte b/ui/lib/components/Invites.svelte
index 27d3754..226ccce 100644
--- a/ui/lib/components/Invites.svelte
+++ b/ui/lib/components/Invites.svelte
@@ -1,15 +1,11 @@
<script>
- import { createInvite } from '$lib/apiServer';
import Invite from '$lib/components/Invite.svelte';
- let invites = $state([]);
+ let { invites, createInvite = async () => {} } = $props();
async function onsubmit(event) {
event.preventDefault();
- let response = await createInvite();
- if (response.status == 200) {
- invites.push(response.data);
- }
+ await createInvite();
}
</script>
diff --git a/ui/lib/components/LogIn.svelte b/ui/lib/components/LogIn.svelte
index 5bfdae2..c49ea3b 100644
--- a/ui/lib/components/LogIn.svelte
+++ b/ui/lib/components/LogIn.svelte
@@ -1,20 +1,29 @@
<script>
- let {
- username = $bindable(),
- password = $bindable(),
- legend = 'sign in',
- disabled,
- onsubmit
- } = $props();
+ let { legend = 'sign in', logIn = async (username, password) => {} } = $props();
+
+ let username = $state();
+ let password = $state();
+ let disabled = $state(false);
+
+ async function onsubmit(event) {
+ event.preventDefault();
+ disabled = true;
+ try {
+ await logIn(username, password);
+ event.target.reset();
+ } finally {
+ disabled = false;
+ }
+ }
</script>
<div>
<form class="form" {onsubmit}>
- <label for="username">
+ <label>
username
<input name="username" type="text" placeholder="username" bind:value={username} {disabled} />
</label>
- <label for="password">
+ <label>
password
<input
name="password"
diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte
index 1cb8fb5..bb24681 100644
--- a/ui/lib/components/LogOut.svelte
+++ b/ui/lib/components/LogOut.svelte
@@ -1,15 +1,9 @@
<script>
- import { goto } from '$app/navigation';
- import { logOut } from '$lib/apiServer.js';
- import { currentUser } from '$lib/store';
+ let { logOut = async () => {} } = $props();
async function onsubmit(event) {
event.preventDefault();
- const response = await logOut();
- if (200 <= response.status && response.status < 300) {
- currentUser.set(null);
- await goto('/login');
- }
+ await logOut();
}
</script>
diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte
index 1b1598b..dacd900 100644
--- a/ui/lib/components/Message.svelte
+++ b/ui/lib/components/Message.svelte
@@ -1,16 +1,15 @@
<script>
import { DateTime } from 'luxon';
- import { deleteMessage } from '$lib/apiServer';
function scroll(message) {
message.scrollIntoView();
}
- let { id, at, body, renderedBody, editable = false } = $props();
+ let { id, at, body, renderedBody, editable = false, deleteMessage = async (id) => {} } = $props();
let deleteArmed = $state(false);
let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT));
- function onDelete(event) {
+ function ondelete(event) {
event.preventDefault();
if (deleteArmed) {
deleteArmed = false;
@@ -29,7 +28,7 @@
<div class="handle">
{atFormatted}
{#if editable}
- <button onclick={onDelete}>&#x1F5D1;&#xFE0F;</button>
+ <button onclick={ondelete}>&#x1F5D1;&#xFE0F;</button>
{/if}
</div>
<section use:scroll class="message-body">
diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 1eb1d7b..69a8298 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -1,29 +1,30 @@
<script>
- import { postToChannel } from '$lib/apiServer';
+ let { sendMessage = async (message) => {} } = $props();
- let { channel } = $props();
-
- let form;
let value = $state('');
let disabled = $state(false);
- async function onSubmit(event) {
+ async function onsubmit(event) {
event.preventDefault();
disabled = true;
- await postToChannel(channel, value);
- form.reset();
- disabled = false;
+ try {
+ await sendMessage(value);
+ event.target.reset();
+ } finally {
+ disabled = false;
+ }
}
- function onKeyDown(event) {
+ function onkeydown(event) {
let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
if (!modifier && event.key === 'Enter') {
- onSubmit(event);
+ event.preventDefault();
+ event.target.form.requestSubmit();
}
}
</script>
-<form bind:this={form} onsubmit={onSubmit}>
- <textarea onkeydown={onKeyDown} bind:value {disabled} placeholder="Say something..."></textarea>
+<form {onsubmit}>
+ <textarea {onkeydown} bind:value {disabled} placeholder="Say something..."></textarea>
<button type="submit">&raquo;</button>
</form>
diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte
index bee64e8..f1facd3 100644
--- a/ui/lib/components/MessageRun.svelte
+++ b/ui/lib/components/MessageRun.svelte
@@ -1,18 +1,14 @@
<script>
- import { logins, currentUser } from '$lib/store';
import Message from '$lib/components/Message.svelte';
- let { sender, messages } = $props();
-
- let name = $derived($logins.get(sender));
- let ownMessage = $derived($currentUser !== null && $currentUser.id == sender);
+ let { sender, messages, ownMessage, deleteMessage = async (id) => {} } = $props();
</script>
<div class="message-run" class:own-message={ownMessage} class:other-message={!ownMessage}>
<span class="username">
- @{name}:
+ @{sender}:
</span>
{#each messages as message}
- <Message {...message} editable={ownMessage} />
+ <Message {...message} editable={ownMessage} {deleteMessage} />
{/each}
</div>
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index 6339abd..02f7d19 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -5,7 +5,7 @@
import { getContext, onDestroy, onMount } from 'svelte';
import TinyGesture from 'tinygesture';
- import { boot, subscribeToEvents } from '$lib/apiServer';
+ import * as api from '$lib/apiServer.js';
import { channelsList, currentUser, logins, messages, onEvent } from '$lib/store';
import ChannelList from '$lib/components/ChannelList.svelte';
@@ -58,20 +58,20 @@
return;
}
gesture = new TinyGesture(window);
- gesture.on('swiperight', (event) => {
+ gesture.on('swiperight', () => {
pageContext.showMenu = true;
});
- gesture.on('swipeleft', (event) => {
+ gesture.on('swipeleft', () => {
pageContext.showMenu = false;
});
}
onMount(async () => {
- let response = await boot();
+ let response = await api.boot();
switch (response.status) {
case 200:
onBooted(response.data);
- events = subscribeToEvents(response.data.resume_point);
+ events = api.subscribeToEvents(response.data.resume_point);
events.onmessage = onEvent.fromMessage;
break;
case 401:
@@ -109,6 +109,10 @@
event.returnValue = '';
return '';
}
+
+ async function createChannel(name) {
+ await api.createChannel(name);
+ }
</script>
<svelte:window {onbeforeunload} />
@@ -125,7 +129,7 @@
<nav id="sidebar" data-expanded={pageContext.showMenu}>
<ChannelList active={channel} channels={enrichedChannels} />
<div class="create-channel">
- <CreateChannelForm />
+ <CreateChannelForm {createChannel} />
</div>
</nav>
<main>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 84cb0ae..8de9859 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -3,10 +3,22 @@
import { page } from '$app/state';
import ActiveChannel from '$lib/components/ActiveChannel.svelte';
import MessageInput from '$lib/components/MessageInput.svelte';
- import { channelsList, messages } from '$lib/store';
+ import { channelsList, currentUser, logins, messages } from '$lib/store';
+ import * as api from '$lib/apiServer';
let channel = $derived(page.params.channel);
- let messageRuns = $derived($messages.inChannel(channel));
+ let messageRuns = $derived(
+ $messages.inChannel(channel).map(({ sender, messages }) => {
+ let senderName = $derived($logins.get(sender));
+ let ownMessage = $derived($currentUser !== null && $currentUser.id === sender);
+
+ return {
+ sender: senderName,
+ ownMessage,
+ messages
+ };
+ })
+ );
let activeChannel;
function inView(parentElement, element) {
@@ -63,13 +75,21 @@
clearTimeout(lastReadCallback); // Fine if lastReadCallback is null still.
lastReadCallback = setTimeout(setLastRead, 2 * 1000);
}
+
+ async function sendMessage(message) {
+ await api.postToChannel(channel, message);
+ }
+
+ async function deleteMessage(id) {
+ await api.deleteMessage(id);
+ }
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="active-channel" {onscroll} bind:this={activeChannel}>
- <ActiveChannel {messageRuns} />
+ <ActiveChannel {messageRuns} {deleteMessage} />
</div>
<div class="create-message">
- <MessageInput {channel} />
+ <MessageInput {sendMessage} />
</div>
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 14a9db8..ab214e9 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -2,10 +2,35 @@
import LogOut from '$lib/components/LogOut.svelte';
import Invites from '$lib/components/Invites.svelte';
import ChangePassword from '$lib/components/ChangePassword.svelte';
+
+ import { goto } from '$app/navigation';
+ import * as api from '$lib/apiServer.js';
+ import { currentUser } from '$lib/store';
+
+ let invites = $state([]);
+
+ async function logOut() {
+ const response = await api.logOut();
+ if (200 <= response.status && response.status < 300) {
+ currentUser.set(null);
+ await goto('/login');
+ }
+ }
+
+ async function changePassword(currentPassword, newPassword) {
+ await api.changePassword(currentPassword, newPassword);
+ }
+
+ async function createInvite() {
+ let response = await api.createInvite();
+ if (response.status === 200) {
+ invites.push(response.data);
+ }
+ }
</script>
-<ChangePassword />
+<ChangePassword {changePassword} />
<hr />
-<Invites />
+<Invites {invites} {createInvite} />
<hr />
-<LogOut />
+<LogOut {logOut} />
diff --git a/ui/routes/(login)/invite/[invite]/+page.svelte b/ui/routes/(login)/invite/[invite]/+page.svelte
index 0c01286..04341e5 100644
--- a/ui/routes/(login)/invite/[invite]/+page.svelte
+++ b/ui/routes/(login)/invite/[invite]/+page.svelte
@@ -1,26 +1,16 @@
<script>
import { goto } from '$app/navigation';
- import { acceptInvite } from '$lib/apiServer';
+ import * as api from '$lib/apiServer';
import LogIn from '$lib/components/LogIn.svelte';
let { data } = $props();
- let username = $state(''),
- password = $state('');
- let pending = false;
- let disabled = $derived(pending);
-
- async function onSubmit(event, inviteId) {
- event.preventDefault();
- pending = true;
- const response = await acceptInvite(inviteId, username, password);
+ async function acceptInvite(inviteId, username, password) {
+ const response = await api.acceptInvite(inviteId, username, password);
if (200 <= response.status && response.status < 300) {
- username = '';
- password = '';
await goto('/');
}
- pending = false;
}
</script>
@@ -32,5 +22,5 @@
<div class="invite-text">
<p>Hi there! {invite.issuer} invites you to the conversation.</p>
</div>
- <LogIn {disabled} bind:username bind:password onsubmit={(event) => onSubmit(event, invite.id)} />
+ <LogIn logIn={async (username, password) => acceptInvite(invite.id, username, password)} />
{/await}
diff --git a/ui/routes/(login)/login/+page.svelte b/ui/routes/(login)/login/+page.svelte
index 9157cef..b1f7cf2 100644
--- a/ui/routes/(login)/login/+page.svelte
+++ b/ui/routes/(login)/login/+page.svelte
@@ -1,25 +1,15 @@
<script>
import { goto } from '$app/navigation';
- import { logIn } from '$lib/apiServer';
+ import * as api from '$lib/apiServer';
import LogIn from '$lib/components/LogIn.svelte';
- let username = '',
- password = '';
- let pending = false;
- $: disabled = pending;
-
- async function onSubmit(event) {
- event.preventDefault();
- pending = true;
- const response = await logIn(username, password);
+ async function logIn(username, password) {
+ const response = await api.logIn(username, password);
if (200 <= response.status && response.status < 300) {
- username = '';
- password = '';
await goto('/');
}
- pending = false;
}
</script>
-<LogIn {disabled} bind:username bind:password onsubmit={onSubmit} />
+<LogIn {logIn} />
diff --git a/ui/routes/(login)/setup/+page.svelte b/ui/routes/(login)/setup/+page.svelte
index c63f198..0b5a824 100644
--- a/ui/routes/(login)/setup/+page.svelte
+++ b/ui/routes/(login)/setup/+page.svelte
@@ -1,25 +1,15 @@
<script>
import { goto } from '$app/navigation';
- import { setup } from '$lib/apiServer';
+ import * as api from '$lib/apiServer';
import LogIn from '$lib/components/LogIn.svelte';
- let username = $state(''),
- password = $state('');
- let pending = false;
- let disabled = $derived(pending);
-
- async function onSubmit(event) {
- event.preventDefault();
- pending = true;
- const response = await setup(username, password);
+ async function logIn(username, password) {
+ const response = await api.setup(username, password);
if (200 <= response.status && response.status < 300) {
- username = '';
- password = '';
await goto('/');
}
- pending = false;
}
</script>
-<LogIn {disabled} bind:username bind:password legend="set up" onsubmit={onSubmit} />
+<LogIn legend="set up" {logIn} />
diff --git a/ui/tests/lib/components/ActiveChannel.svelte.test.js b/ui/tests/lib/components/ActiveChannel.svelte.test.js
deleted file mode 100644
index 183c823..0000000
--- a/ui/tests/lib/components/ActiveChannel.svelte.test.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test } from 'vitest';
-import ActiveChannel from '$lib/components/ActiveChannel.svelte';
-
-let component;
-
-describe('ActiveChannel', () => {
- beforeEach(() => {
- component = mount(ActiveChannel, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
- });
-
- afterEach(() => {
- unmount(component);
- });
-
- test('mounts', async () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/ui/tests/lib/components/ChangePassword.svelte.test.js b/ui/tests/lib/components/ChangePassword.svelte.test.js
index 9eb40f5..9db6974 100644
--- a/ui/tests/lib/components/ChangePassword.svelte.test.js
+++ b/ui/tests/lib/components/ChangePassword.svelte.test.js
@@ -1,52 +1,66 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, describe, it, vi } from 'vitest';
import ChangePassword from '$lib/components/ChangePassword.svelte';
-import axios from 'axios';
-let component;
+const user = userEvent.setup();
-let mocks = vi.hoisted(() => ({
- post: vi.fn()
+const mocks = vi.hoisted(() => ({
+ changePassword: vi.fn()
}));
-describe('ChangePassword', () => {
- beforeEach(() => {
- vi.mock('axios', async (importActual) => {
- const actual = await importActual();
-
- const mockAxios = {
- default: {
- ...actual.default,
- create: vi.fn(() => ({
- ...actual.default.create(),
- post: mocks.post
- }))
- }
- };
-
- return mockAxios;
+describe('ChangePassword', async () => {
+ beforeEach(async () => {
+ render(ChangePassword, {
+ changePassword: mocks.changePassword
});
- mocks.post.mockResolvedValue({ status: 200 });
+ });
- component = mount(ChangePassword, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
+ it('submits valid password changes', async () => {
+ const oldPassword = screen.getByLabelText('current password');
+ await user.type(oldPassword, 'old password');
+
+ const newPassword = screen.getByLabelText('new password');
+ await user.type(newPassword, 'new password');
+
+ const confirmPassword = screen.getByLabelText('confirm new password');
+ await user.type(confirmPassword, 'new password');
+
+ const create = screen.getByRole('button');
+ await user.click(create);
+
+ expect(mocks.changePassword).toHaveBeenCalledExactlyOnceWith('old password', 'new password');
});
- afterEach(() => {
- unmount(component);
+ it('is disabled when old password matches new password', async () => {
+ const oldPassword = screen.getByLabelText('new password');
+ await user.type(oldPassword, 'old password');
+
+ const newPassword = screen.getByLabelText('new password');
+ await user.type(newPassword, 'new password');
+
+ const confirmPassword = screen.getByLabelText('confirm new password');
+ await user.type(confirmPassword, 'new password');
+
+ const create = screen.getByRole('button');
+ await user.click(create);
+
+ expect(mocks.changePassword).not.toHaveBeenCalled();
});
- test('onsubmit happy path', async () => {
- // Set value in all three inputs at once:
- const inputs = document.body.querySelectorAll('input[type=password]');
- inputs.value = 'pass';
- // Click the button, then flush the changes so you can synchronously write expectations
- document.body.querySelector('button[type=submit]').click();
- flushSync();
+ it('is disabled when new passwords differ', async () => {
+ const oldPassword = screen.getByLabelText('new password');
+ await user.type(oldPassword, 'old password');
+
+ const newPassword = screen.getByLabelText('new password');
+ await user.type(newPassword, 'new password A');
+
+ const confirmPassword = screen.getByLabelText('confirm new password');
+ await user.type(confirmPassword, 'new password B');
+
+ const create = screen.getByRole('button');
+ await user.click(create);
- // expect(axios.post).toHaveBeenCalledWith('/password', { password: 'pass', to: 'pass' });
- expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['', '', '']);
+ expect(mocks.changePassword).not.toHaveBeenCalled();
});
});
diff --git a/ui/tests/lib/components/Channel.svelte.test.js b/ui/tests/lib/components/Channel.svelte.test.js
deleted file mode 100644
index a6fdab9..0000000
--- a/ui/tests/lib/components/Channel.svelte.test.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test } from 'vitest';
-import Channel from '$lib/components/Channel.svelte';
-
-let component;
-
-describe('Channel', () => {
- beforeEach(() => {
- component = mount(Channel, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
- });
-
- afterEach(() => {
- unmount(component);
- });
-
- test('mounts', async () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/ui/tests/lib/components/ChannelList.svelte.test.js b/ui/tests/lib/components/ChannelList.svelte.test.js
deleted file mode 100644
index 7b5ae4a..0000000
--- a/ui/tests/lib/components/ChannelList.svelte.test.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test } from 'vitest';
-import ChannelList from '$lib/components/ChannelList.svelte';
-
-let component;
-
-describe('ChannelList', () => {
- beforeEach(() => {
- component = mount(ChannelList, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
- });
-
- afterEach(() => {
- unmount(component);
- });
-
- test('mounts', async () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
index a4a4695..15d3cfd 100644
--- a/ui/tests/lib/components/CreateChannelForm.svelte.test.js
+++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
@@ -1,52 +1,37 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, describe, it, vi } from 'vitest';
import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
-import axios from 'axios';
-let component;
+const user = userEvent.setup();
-let mocks = vi.hoisted(() => ({
- post: vi.fn()
+const mocks = vi.hoisted(() => ({
+ createChannel: vi.fn()
}));
-describe('CreateChannelForm', () => {
- beforeEach(() => {
- vi.mock('axios', async (importActual) => {
- const actual = await importActual();
-
- const mockAxios = {
- default: {
- ...actual.default,
- create: vi.fn(() => ({
- ...actual.default.create(),
- post: mocks.post
- }))
- }
- };
-
- return mockAxios;
+describe('CreateChannelForm', async () => {
+ beforeEach(async () => {
+ render(CreateChannelForm, {
+ createChannel: mocks.createChannel
});
- mocks.post.mockResolvedValue({ status: 200 });
-
- component = mount(CreateChannelForm, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
});
- afterEach(() => {
- unmount(component);
- });
+ describe('creates channels', async () => {
+ it('with a non-empty name', async () => {
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'channel name');
- test('onsubmit happy path', async () => {
- // Set value on the one input this should match:
- const inputs = document.body.querySelectorAll('input[type=text]');
- inputs.value = 'channel name';
- // Click the button, then flush the changes so you can synchronously write expectations
- document.body.querySelector('button[type=submit]').click();
- flushSync();
+ const create = screen.getByRole('button');
+ await user.click(create);
- expect(mocks.post).toHaveBeenCalled();
- expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['']);
+ expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('channel name');
+ });
+
+ it('with an empty name', async () => {
+ const create = screen.getByRole('button');
+ await user.click(create);
+
+ expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('');
+ });
});
});
diff --git a/ui/tests/lib/components/LogIn.svelte.test.js b/ui/tests/lib/components/LogIn.svelte.test.js
index b64d846..ab77c11 100644
--- a/ui/tests/lib/components/LogIn.svelte.test.js
+++ b/ui/tests/lib/components/LogIn.svelte.test.js
@@ -1,22 +1,34 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, describe, it, vi } from 'vitest';
import LogIn from '$lib/components/LogIn.svelte';
-let component;
+const user = userEvent.setup();
-describe('LogIn', () => {
- beforeEach(() => {
- component = mount(LogIn, {
- target: document.body // `document` exists because of jsdom
+const mocks = vi.hoisted(() => ({
+ logIn: vi.fn()
+}));
+
+describe('LogIn', async () => {
+ beforeEach(async () => {
+ render(LogIn, {
+ logIn: mocks.logIn
});
- flushSync();
});
- afterEach(() => {
- unmount(component);
- });
+ it('sends a login request', async () => {
+ const username = screen.getByLabelText('username');
+ await user.type(username, 'my username');
+
+ const password = screen.getByLabelText('password');
+ await user.type(password, 'my very creative and long password');
+
+ const signIn = screen.getByRole('button');
+ await user.click(signIn);
- test('mounts', async () => {
- expect(component).toBeTruthy();
+ expect(mocks.logIn).toHaveBeenCalledExactlyOnceWith(
+ 'my username',
+ 'my very creative and long password'
+ );
});
});
diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js
index 3dde5d7..508fb43 100644
--- a/ui/tests/lib/components/MessageInput.svelte.test.js
+++ b/ui/tests/lib/components/MessageInput.svelte.test.js
@@ -1,48 +1,37 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, describe, it, vi } from 'vitest';
import MessageInput from '$lib/components/MessageInput.svelte';
-import axios from 'axios';
-let component;
+const user = userEvent.setup();
-let mocks = vi.hoisted(() => ({
- post: vi.fn()
+const mocks = vi.hoisted(() => ({
+ sendMessage: vi.fn()
}));
-describe('MessageInput', () => {
- beforeEach(() => {
- vi.mock('axios', async (importActual) => {
- const actual = await importActual();
-
- const mockAxios = {
- default: {
- ...actual.default,
- create: vi.fn(() => ({
- ...actual.default.create(),
- post: mocks.post
- }))
- }
- };
-
- return mockAxios;
+describe('CreateChannelForm', async () => {
+ beforeEach(async () => {
+ render(MessageInput, {
+ sendMessage: mocks.sendMessage
});
- mocks.post.mockResolvedValue({ status: 200 });
-
- component = mount(MessageInput, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
});
- afterEach(() => {
- unmount(component);
- });
+ describe('sends a message', async () => {
+ it('with non-empty content', async () => {
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'a happy surprise');
- test('onsubmit happy path', async () => {
- // Click the button, then flush the changes so you can synchronously write expectations
- document.body.querySelector('button[type=submit]').click();
- flushSync();
+ const send = screen.getByRole('button');
+ await user.click(send);
- expect(mocks.post).toHaveBeenCalled();
+ expect(mocks.sendMessage).toHaveBeenCalledExactlyOnceWith('a happy surprise');
+ });
+
+ it('with empty content', async () => {
+ const send = screen.getByRole('button');
+ await user.click(send);
+
+ expect(mocks.sendMessage).toHaveBeenCalledExactlyOnceWith('');
+ });
});
});
diff --git a/ui/tests/lib/components/MessageRun.svelte.test.js b/ui/tests/lib/components/MessageRun.svelte.test.js
deleted file mode 100644
index 7671c52..0000000
--- a/ui/tests/lib/components/MessageRun.svelte.test.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { flushSync, mount, unmount } from 'svelte';
-import { afterEach, beforeEach, describe, expect, test } from 'vitest';
-import MessageRun from '$lib/components/MessageRun.svelte';
-
-let component;
-
-describe('MessageRun', () => {
- beforeEach(() => {
- component = mount(MessageRun, {
- target: document.body // `document` exists because of jsdom
- });
- flushSync();
- });
-
- afterEach(() => {
- unmount(component);
- });
-
- test('mounts', async () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/vite.config.js b/vite.config.js
index e74647a..4bd448f 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,14 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { configDefaults } from 'vitest/config'
+import { svelteTesting } from '@testing-library/svelte/vite'
export default defineConfig({
- plugins: [sveltekit()],
+ plugins: [sveltekit(), svelteTesting()],
test: {
// If you are testing components client-side, you need to setup a DOM environment.
// If not all your files should have this environment, you can use a
// `// @vitest-environment jsdom` comment at the top of the test files instead.
environment: 'jsdom',
+ restoreMocks: true,
coverage: {
thresholds: {
statements: 49,
@@ -26,7 +28,7 @@ export default defineConfig({
},
// Tell Vitest to use the `browser` entry points in `package.json` files,
// even though it's running in Node
- resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
+ resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
server: {
fs: {
allow: [