diff options
| author | Ruben Beltran del Rio <ruben@unlimited.pizza> | 2022-05-01 00:56:06 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <ruben@unlimited.pizza> | 2022-05-01 00:56:06 +0200 |
| commit | a7cf03c192470cbab13edeb1aec99e0c66dede10 (patch) | |
| tree | 581b4430d1de958dcb666bae80a7678332134602 /src/lib/components | |
| parent | 010f307346e525ac2e4239a0549d2c1a4d6d102b (diff) | |
Update / use typescript
Diffstat (limited to 'src/lib/components')
27 files changed, 1177 insertions, 0 deletions
diff --git a/src/lib/components/actions/topic.svelte b/src/lib/components/actions/topic.svelte new file mode 100644 index 0000000..1311d06 --- /dev/null +++ b/src/lib/components/actions/topic.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { TopicAction } from '$lib/stores/action'; + export let actions: TopicAction; + + import { _ } from 'svelte-i18n'; +</script> + +<li> + <a href="/reply/{actions.id}" title={$_('header.action.reply.title')}> + {@html $_('header.action.reply.display')} + </a> +</li> + +<style> + li { + display: inline; + margin: 5px; + } + + a { + text-decoration: none; + line-height: 3em; + display: inline-block; + } +</style> diff --git a/src/lib/components/actions/topic.test.ts b/src/lib/components/actions/topic.test.ts new file mode 100644 index 0000000..0037a7f --- /dev/null +++ b/src/lib/components/actions/topic.test.ts @@ -0,0 +1,29 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Topic from './topic.svelte'; + +const internals = { + results: null +}; + +describe('Topic Actions component', () => { + + test('Should link to reply page', () => { + + const results = render(Topic, { props: { + actions: { + id: '8ebaa211-fd9b-423a-8b4f-b57622007fde' + } + } }); + + expect(results.getByTitle('Reply').closest('a')) + .toHaveAttribute('href', '/reply/8ebaa211-fd9b-423a-8b4f-b57622007fde'); + }); +}); diff --git a/src/lib/components/author/author.svelte b/src/lib/components/author/author.svelte new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/lib/components/author/author.svelte diff --git a/src/lib/components/error_block/error_block.svelte b/src/lib/components/error_block/error_block.svelte new file mode 100644 index 0000000..d25f507 --- /dev/null +++ b/src/lib/components/error_block/error_block.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + export let message = null; + + import { blink } from '$lib/animations/blink'; +</script> + +<div transition:blink> + <h2>{$_('error.generic.title')}</h2> + <p>{message || $_('error.generic.message')}</p> +</div> + +<style> + div { + background: repeating-linear-gradient(-45deg, red, red 5px, black 5px, black 10px); + border: 5px solid red; + color: yellow; + font-family: 'ヒラギノ角ゴ ProN', 'Hiragino Kaku Gothic ProN', '游ゴシック', '游ゴシック体', + YuGothic, 'Yu Gothic', 'メイリオ', Meiryo, 'MS ゴシック', 'MS Gothic', HiraKakuProN-W3, + 'TakaoExゴシック', TakaoExGothic, 'MotoyaLCedar', 'Droid Sans Japanese', sans-serif; + margin: 0 10px 0 0; + text-align: center; + overflow: hidden; + } + + h2, + p { + background-color: black; + font-size: 1em; + } + + h2 { + text-transform: uppercase; + margin: 100px 5px 10px; + } + + p { + margin: 10px 5px 100px; + } +</style> diff --git a/src/lib/components/error_block/error_block.test.ts b/src/lib/components/error_block/error_block.test.ts new file mode 100644 index 0000000..d2ac458 --- /dev/null +++ b/src/lib/components/error_block/error_block.test.ts @@ -0,0 +1,32 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import ErrorBlock from './error_block.svelte'; + +describe('Error Block component', () => { + + test('Should display error message sent', () => { + + const results = render(ErrorBlock, { props: { + message: 'An error has, sadly, destroyed everything.' + } }); + + expect(results.getByText('An error has, sadly, destroyed everything.')) + .toBeVisible(); + }); + + test('Should display default error message', () => { + + const results = render(ErrorBlock); + + expect(results.getByText('Unknown error has occurred. Panic!')) + .toBeVisible(); + }); +}); + diff --git a/src/lib/components/footer/footer.svelte b/src/lib/components/footer/footer.svelte new file mode 100644 index 0000000..5c171d2 --- /dev/null +++ b/src/lib/components/footer/footer.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + + import LanguageSelector from '$lib/components/language_selector/language_selector.svelte'; + + const licenseUrl = 'https://gitlab.com/rbdr/forum/'; +</script> + +<footer title={$_('footer.title')}> + <ul> + <li>{@html $_('footer.license', { values: { licenseUrl } })}</li> + <li>{$_('footer.choose_language')}: <LanguageSelector /></li> + </ul> +</footer> + +<style> + footer { + grid-column: col-start 1 / span 12; + border-top: 1px solid black; + } + + ul { + padding: 0; + } + + li { + display: inline; + margin: 5px; + } +</style> diff --git a/src/lib/components/forum/forum.svelte b/src/lib/components/forum/forum.svelte new file mode 100644 index 0000000..b886428 --- /dev/null +++ b/src/lib/components/forum/forum.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + export let forum; + + import { _ } from 'svelte-i18n'; + import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte'; +</script> + +<h1>{forum.glyph} {$_(forum.label)}</h1> +<ul> + {#each forum.topics as topic} + <TopicSummary {topic} /> + {/each} +</ul> diff --git a/src/lib/components/forum/forum.test.ts b/src/lib/components/forum/forum.test.ts new file mode 100644 index 0000000..b3ce167 --- /dev/null +++ b/src/lib/components/forum/forum.test.ts @@ -0,0 +1,73 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { addMessages } from 'svelte-i18n'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Forum from './forum.svelte'; + +const internals = { + results: null +}; + +describe('Forum component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.oleo': 'Oleo' + }); + }); + + beforeEach(() => { + + internals.results = render(Forum, { props: { + forum: { + id: 'oleo', + glyph: '☽', + label: 'test_forums.oleo', + topics: [ + { + id: '0575d375-5bea-44df-a597-bee3adda624d', + title: 'Very forumy topic', + ttl: 160 * 1000, + updated_at: Date.now() + }, + { + id: 'aeeb56e4-751d-4400-8aa7-d0f3a20d4e25', + title: 'Only mildly forum-like', + ttl: 160 * 1000, + updated_at: Date.now() + } + ] + } + } }); + }); + + test('It should display the forum glyph and label', () => { + + expect(internals.results.getByText(/^\s*☽\s*Oleo\s*$/)) + .toBeVisible(); + }); + + test('It should display the topics', () => { + + expect(internals.results.getByText('Very forumy topic')) + .toBeVisible(); + expect(internals.results.getByText('Only mildly forum-like')) + .toBeVisible(); + }); + + test('It should link to the topics', () => { + + expect(internals.results.getByText('Very forumy topic').closest('a')) + .toHaveAttribute('href', '/t/0575d375-5bea-44df-a597-bee3adda624d'); + expect(internals.results.getByText('Only mildly forum-like').closest('a')) + .toHaveAttribute('href', '/t/aeeb56e4-751d-4400-8aa7-d0f3a20d4e25'); + }); +}); diff --git a/src/lib/components/forum_list/forum_list.svelte b/src/lib/components/forum_list/forum_list.svelte new file mode 100644 index 0000000..3529de8 --- /dev/null +++ b/src/lib/components/forum_list/forum_list.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + export let forums; + + $: sortedForums = forums.slice().sort((a, b) => a.position - b.position); +</script> + +<ul> + {#each sortedForums as forum} + <li> + <a href="/f/{forum.id}"> + <span aria-hidden="true" class="navigation-glyph {forum.glyph}">{forum.glyph}</span> + <span class="navigation-label">{$_(forum.label)}</span> + </a> + </li> + {/each} +</ul> + +<style> + ul { + padding: 0; + } + + li { + display: block; + text-align: left; + margin-bottom: 20px; + } + + .navigation-glyph { + font-size: 1.5rem; + display: block; + } + + .☽ { + font-size: 2rem; + } + + a { + text-decoration: none; + } +</style> diff --git a/src/lib/components/forum_list/forum_list.test.ts b/src/lib/components/forum_list/forum_list.test.ts new file mode 100644 index 0000000..be7d449 --- /dev/null +++ b/src/lib/components/forum_list/forum_list.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import { addMessages } from 'svelte-i18n'; + +import ForumList from './forum_list.svelte'; + +const internals = { + results: null +}; + +describe('Forum List component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.yes': 'Absolutely yes', + 'test_forums.no': 'No, not at all', + 'test_forums.maybe': 'OK, maybe...' + }); + }); + + beforeEach(() => { + + internals.results = render(ForumList, { props: { + forums: [ + { + id: 'yes', + glyph: '☆', + label: 'test_forums.yes', + position: 2 + }, + { + id: 'no', + glyph: '◯', + label: 'test_forums.no', + position: 0 + }, + { + id: 'maybe', + glyph: '⏀', + label: 'test_forums.maybe', + position: 1 + } + ] + } }); + }); + + test('It should display each forum according to their position', () => { + + expect(internals.results.container) + .toHaveTextContent(/^◯.+⏀.+☆.+$/); + }); + + test('It should translate forum labels', () => { + + expect(internals.results.getByText('Absolutely yes')).toBeVisible(); + expect(internals.results.getByText('No, not at all')).toBeVisible(); + expect(internals.results.getByText('OK, maybe...')).toBeVisible(); + }); + + test('It should display forum glyphs', () => { + + expect(internals.results.getByText('☆')).toBeVisible(); + expect(internals.results.getByText('◯')).toBeVisible(); + expect(internals.results.getByText('⏀')).toBeVisible(); + }); + + test('Label should be a permalink to the forum', () => { + + expect(internals.results.getByText('Absolutely yes').closest('a')) + .toHaveAttribute('href', '/f/yes'); + expect(internals.results.getByText('No, not at all').closest('a')) + .toHaveAttribute('href', '/f/no'); + expect(internals.results.getByText('OK, maybe...').closest('a')) + .toHaveAttribute('href', '/f/maybe'); + }); + + test('Glyph should be a permalink to the forum', () => { + + expect(internals.results.getByText('☆').closest('a')) + .toHaveAttribute('href', '/f/yes'); + expect(internals.results.getByText('◯').closest('a')) + .toHaveAttribute('href', '/f/no'); + expect(internals.results.getByText('⏀').closest('a')) + .toHaveAttribute('href', '/f/maybe'); + }); +}); diff --git a/src/lib/components/glyph/glyph.svelte b/src/lib/components/glyph/glyph.svelte new file mode 100644 index 0000000..0559b98 --- /dev/null +++ b/src/lib/components/glyph/glyph.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + import { getGlyphHash } from '$lib/utils/glyph_hash'; + + export let uuid: string; +</script> + +<div class="glyphicon" role="img" aria-label={$_('glyph.title')} title={$_('glyph.title')}> + {#each getGlyphHash(uuid) as fragment} + <span class={fragment.glyph} style="color: {fragment.color} "> + {fragment.glyph} + </span> + {/each} +</div> + +<style> + .glyphicon { + border: 1px solid black; + display: inline-block; + font-size: 1.4rem; + height: 48px; + margin-top: 5px; + width: 48px; + background-color: white; + padding: 2px; + } + + span { + display: block; + float: left; + width: 24px; + height: 24px; + text-align: center; + line-height: 24px; + } + + .☽ { + font-size: 2rem; + line-height: 19px; + } +</style> diff --git a/src/lib/components/glyph/glyph.test.ts b/src/lib/components/glyph/glyph.test.ts new file mode 100644 index 0000000..778353f --- /dev/null +++ b/src/lib/components/glyph/glyph.test.ts @@ -0,0 +1,36 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Glyph from './glyph.svelte'; + +const internals = { + results: null +}; + +describe('Glyph component', () => { + + beforeEach(() => { + + internals.results = render(Glyph, { props: { + uuid: '9fb14ebc-bc64-400b-915f-d429ec44b8fe' + } }); + }); + + test('Should act as an image', () => { + + expect(internals.results.getByRole('img')) + .toBeVisible(); + }); + + test('Should render 4 glyphs', () => { + + expect(internals.results.getByRole('img')) + .toHaveTextContent(/^. . . .$/); + }); +}); diff --git a/src/lib/components/header/header.svelte b/src/lib/components/header/header.svelte new file mode 100644 index 0000000..e6c9348 --- /dev/null +++ b/src/lib/components/header/header.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + import { version } from '$lib/config/config'; + import { topicActions } from '$lib/stores/actions'; + import TopicActions from '$lib/components/actions/topic.svelte'; +</script> + +<header title={$_('header.title')}> + <ul> + <li> + <strong + ><a href="/" title={$_('header.long_version', { values: { version } })} + >{$_('header.short_version', { values: { version } })}</a + ></strong + > + </li> + <li> + <a href="/new" title={$_('header.action.new.title')} + >{@html $_('header.action.new.display')}</a + > + </li> + {#if $topicActions} + <TopicActions actions={$topicActions} /> + {/if} + <li> + <a href="/search" title={$_('header.action.search.title')} + >{@html $_('header.action.search.display')}</a + > + </li> + <li> + <a href="/logout" title={$_('header.action.log_out.title')} + >{@html $_('header.action.log_out.display')}</a + > + </li> + </ul> +</header> + +<style> + header { + grid-column: col-start 1 / span 12; + border-bottom: 1px solid black; + } + + ul { + padding: 0; + } + + li { + display: inline; + margin: 5px; + } + + a { + text-decoration: none; + line-height: 3em; + display: inline-block; + } + + strong a { + color: blue; + text-decoration: underline; + } +</style> diff --git a/src/lib/components/header/header.test.ts b/src/lib/components/header/header.test.ts new file mode 100644 index 0000000..4107b2d --- /dev/null +++ b/src/lib/components/header/header.test.ts @@ -0,0 +1,33 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; +import { enableTopicActions } from '$lib/stores/actions'; + +jest.mock('$lib/config/config.ts'); + +import Header from './header.svelte'; + +describe('Header component', () => { + + test('Should not display topic if action is not set', () => { + + const results = render(Header); + + expect(results.queryByTitle('Reply')) + .toBe(null); + }); + + test('Should display topic if action is set', () => { + + enableTopicActions('d138d6d8-e669-42e7-995d-20a7fcc176f5'); + const results = render(Header); + + expect(results.getByTitle('Reply')) + .toBeVisible(); + }); +}); diff --git a/src/lib/components/home/home.svelte b/src/lib/components/home/home.svelte new file mode 100644 index 0000000..9473bbb --- /dev/null +++ b/src/lib/components/home/home.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<h1>{$_('home.title')}</h1> +<p>{$_('home.content')}</p> diff --git a/src/lib/components/invalid_route/invalid_route.svelte b/src/lib/components/invalid_route/invalid_route.svelte new file mode 100644 index 0000000..a75316d --- /dev/null +++ b/src/lib/components/invalid_route/invalid_route.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<h1>{$_('error.invalid_url.title')}</h1> +<p>{$_('error.invalid_url.message')}</p> diff --git a/src/lib/components/language_selector/language_selector.svelte b/src/lib/components/language_selector/language_selector.svelte new file mode 100644 index 0000000..64a4ef9 --- /dev/null +++ b/src/lib/components/language_selector/language_selector.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { locale, locales } from 'svelte-i18n'; + import { getLangNameFromCode } from 'language-name-map'; + + $: namedLocales = $locales + .map((code) => ({ + code, + ...getLangNameFromCode(code) + })) + .sort((a, b) => a.native - b.native); + + let selected = $locale + + $: { + locale.set(selected); + } +</script> + +<select bind:value={selected}> + {#each namedLocales as namedLocale} + <option value={namedLocale.code}>{namedLocale.native}</option> + {/each} +</select> + +<style> +</style> diff --git a/src/lib/components/language_selector/language_selector.test.ts b/src/lib/components/language_selector/language_selector.test.ts new file mode 100644 index 0000000..74156bc --- /dev/null +++ b/src/lib/components/language_selector/language_selector.test.ts @@ -0,0 +1,48 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { locale } from 'svelte-i18n'; +import { act, render } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import '$lib/i18n'; + +import LanguageSelector from './language_selector.svelte'; + +const internals = { + results: null +}; + +describe('Language Selector component', () => { + + beforeEach(() => { + + internals.results = render(LanguageSelector); + }); + + test('Should display languages in their own language', () => { + + expect(internals.results.getByText('English')) + .toBeVisible(); + expect(internals.results.getByText('Español')) + .toBeVisible(); + }); + + test('Should change locale when a language is selected', async () => { + + locale.subscribe((localeValue) => { + + expect(localeValue).toBe('en'); + })(); + const spanish = internals.results.getByText('Español'); + await userEvent.selectOptions(spanish.closest('select'), spanish); + await act(); + locale.subscribe((localeValue) => { + + expect(localeValue).toBe('es'); + })(); + }); +}); + diff --git a/src/lib/components/loader/loader.svelte b/src/lib/components/loader/loader.svelte new file mode 100644 index 0000000..dad0957 --- /dev/null +++ b/src/lib/components/loader/loader.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<p>{$_('loader.message')}</p> diff --git a/src/lib/components/post/post.svelte b/src/lib/components/post/post.svelte new file mode 100644 index 0000000..4e6c28f --- /dev/null +++ b/src/lib/components/post/post.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + export let post; + export let index = 0; + export let count = 1; + + import { _ } from 'svelte-i18n'; + import Glyph from '$lib/components/glyph/glyph.svelte'; + + const timestampToISO = (timestamp) => new Date(timestamp).toISOString(); +</script> + +<aside + class="post-meta" + title={$_('post.metadata_title', { values: { count: index + 1, total: count } })} +> + <Glyph uuid={post.author.id} /> + <span class="h-card"> + {$_('post.author_credit')} + <a href="/a/{post.author.handle}" class="p-nickname u-url">{post.author.handle}</a>. + </span> + <time role="presentation" class="dt-published" datetime={timestampToISO(post.created_at)}> + <a title={$_('post.permalink_title')} href="/p/{post.id}"> + {timestampToISO(post.created_at)} + </a> + </time> + {#if post.topic} + <span> + ({$_('post.topic_location')} <a href="/t/{post.topic.id}">{post.topic.title}</a>.) + </span> + {/if} +</aside> +<article + class="e-content" + title={$_('post.title', { + values: { count: index + 1, total: count, author: post.author.handle } + })} +> + {post.text} +</article> +<hr /> + +<style> + .post-meta * { + vertical-align: top; + } + + article { + white-space: pre; + } +</style> diff --git a/src/lib/components/post/post.test.ts b/src/lib/components/post/post.test.ts new file mode 100644 index 0000000..d43583e --- /dev/null +++ b/src/lib/components/post/post.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { cleanup, render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Post from './post.svelte'; + +const internals = { + basicPost: { + id: 'e5a19d53-4c9a-4be8-afa5-00942ea3afa4', + text: 'This is an example post qwerty', + created_at: Date.UTC(2021, 3, 19, 6, 6, 6, 666).valueOf(), + author: { + handle: 'very_cool_user', + id: 'b01bdb48-4b5e-46a4-97f3-6db789bcd33b' + }, + topic: { + id: '35d3c3eb-e486-42ef-994c-d8ab1f1e167a', + title: 'Parent topic, yes' + } + }, + postWithoutTopic: { + id: '9e52e38e-9007-4a20-bbf1-cea4e2f950f3', + text: 'This is a post without a topic', + created_at: Date.UTC(2022, 8, 21, 4, 3, 1, 340).valueOf(), + author: { + handle: 'my_normal_user', + id: '121f8f97-de02-4102-b25d-f34fd619009b' + } + }, + + results: null +}; + +describe('Post component', () => { + + beforeEach(() => { + + internals.results = render(Post, { props: { + post: internals.basicPost + } }); + }); + + test('Should display the text of the post', () => { + + expect(internals.results.getByText('This is an example post qwerty')).toBeVisible(); + }); + + test('Should display date of the post', () => { + + expect(internals.results.getByText('2021-04-19T06:06:06.666Z')) + .toBeVisible(); + }); + + test('Date of post should be a permalink to the post', () => { + + expect(internals.results.getByText('2021-04-19T06:06:06.666Z').closest('a')) + .toHaveAttribute('href', '/p/e5a19d53-4c9a-4be8-afa5-00942ea3afa4'); + }); + + test('Should display the glyph of the post author', () => { + + const glyphicon = internals.results.getByRole('img'); + + expect(glyphicon) + .toBeVisible(); + expect(glyphicon) + .toHaveTextContent(/^. . . .$/); + }); + + test('Should display author handle', () => { + + expect(internals.results.getByText('very_cool_user')) + .toBeVisible(); + }); + + test('Author handle should have a permalink to topic', () => { + + expect(internals.results.getByText('very_cool_user').closest('a')) + .toHaveAttribute('href', '/a/very_cool_user'); + }); + + test('Should display parent topic title', () => { + + expect(internals.results.getByText('Parent topic, yes')) + .toBeVisible(); + }); + + test('Parent topic title should have a permalink to topic', () => { + + expect(internals.results.getByText('Parent topic, yes').closest('a')) + .toHaveAttribute('href', '/t/35d3c3eb-e486-42ef-994c-d8ab1f1e167a'); + }); + + test('Parent topic title should have a permalink to topic', () => { + + cleanup(); + internals.results = render(Post, { props: { + post: internals.postWithoutTopic + } }); + + expect(internals.results.queryByText('Parent topic, yes')) + .toBe(null); + }); + + test('It should default to 1/1 when no index or count is passed', () => { + + expect(internals.results.getByTitle('Post 1 of 1 by very_cool_user')) + .toBeVisible(); + }); + + test('Parent topic title should have a permalink to topic', () => { + + cleanup(); + internals.results = render(Post, { props: { + index: 2, + count: 5, + post: internals.postWithoutTopic + } }); + + expect(internals.results.getByTitle('Post 3 of 5 by my_normal_user')) + .toBeVisible(); + }); +}); diff --git a/src/lib/components/tag/tag.svelte b/src/lib/components/tag/tag.svelte new file mode 100644 index 0000000..86d0b82 --- /dev/null +++ b/src/lib/components/tag/tag.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + export let tag; + + import { _ } from 'svelte-i18n'; + import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte'; +</script> + +<h1>{$_('tag.title')}: {tag.id}</h1> +<ul> + {#each tag.topics as topic} + <TopicSummary {topic} /> + {/each} +</ul> diff --git a/src/lib/components/tag/tag.test.ts b/src/lib/components/tag/tag.test.ts new file mode 100644 index 0000000..76304e6 --- /dev/null +++ b/src/lib/components/tag/tag.test.ts @@ -0,0 +1,62 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Tag from './tag.svelte'; + +const internals = { + results: null +}; + +describe('Tag component', () => { + + beforeEach(() => { + + internals.results = render(Tag, { props: { + tag: { + id: 'avocado', + topics: [ + { + id: 'eb751e7a-5777-46c3-b81b-cc66546d5157', + title: 'A single topic', + ttl: 160 * 1000, + updated_at: Date.now() + }, + { + id: 'b4a5613c-237b-4147-a867-9c105d51e365', + title: 'And its companion', + ttl: 160 * 1000, + updated_at: Date.now() + } + ] + } + } }); + }); + + test('It should display the tag title', () => { + + expect(internals.results.getByText('Tag: avocado')) + .toBeVisible(); + }); + + test('It should display the topics', () => { + + expect(internals.results.getByText('A single topic')) + .toBeVisible(); + expect(internals.results.getByText('And its companion')) + .toBeVisible(); + }); + + test('It should link to the topics', () => { + + expect(internals.results.getByText('A single topic').closest('a')) + .toHaveAttribute('href', '/t/eb751e7a-5777-46c3-b81b-cc66546d5157'); + expect(internals.results.getByText('And its companion').closest('a')) + .toHaveAttribute('href', '/t/b4a5613c-237b-4147-a867-9c105d51e365'); + }); +}); diff --git a/src/lib/components/topic/topic.svelte b/src/lib/components/topic/topic.svelte new file mode 100644 index 0000000..06f2aeb --- /dev/null +++ b/src/lib/components/topic/topic.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + export let topic; + + import { _ } from 'svelte-i18n'; + import Post from '$lib/components/post/post.svelte'; + import { readableTime } from '$lib/utils/readable_time'; + + $: remainingTime = topic.updated_at + topic.ttl - Date.now(); + $: remaining = readableTime(remainingTime); +</script> + +<div class="h-entry" title={$_('topic.title')}> + <h1 class="p-name">{topic.title}</h1> + <aside class="topic-meta" title={$_('topic.metadata_title')}> + {#if topic.forum} + <span class="topic-location"> + {$_('topic.category_location')} + <a href="/f/{topic.forum.id}" class="p-category"> + {topic.forum.glyph} {$_(topic.forum.label)} + </a>. + </span> + {/if} + <span class="topic-ttl"> + <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}"> + ({$_('topic.remaining_time', { + values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) } + })}) + </a>. + </span> + </aside> + {#if topic.tags.length > 0} + <aside class="topic-tags" title={$_('topic.tags_title')}> + {$_('topic.tags_location')} + {#each topic.tags as tag} + <a href="/g/{tag.id}" class="p-category"> + {tag.id}<span class="tag-weight">({tag.weight})</span> + </a>{' '} + {/each} + </aside> + {/if} + {#each topic.posts as post, index} + <Post {post} {index} count={topic.posts.length} /> + {/each} +</div> diff --git a/src/lib/components/topic/topic.test.ts b/src/lib/components/topic/topic.test.ts new file mode 100644 index 0000000..985ca72 --- /dev/null +++ b/src/lib/components/topic/topic.test.ts @@ -0,0 +1,168 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { addMessages } from 'svelte-i18n'; + +import { cleanup, render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Topic from './topic.svelte'; + +const internals = { + results: null, + basicTopic: { + id: 'b1a4f8d1-4d16-4872-b391-fda6a0e9012d', + title: 'I sure am a test topic', + ttl: 160 * 1000, + updated_at: Date.now(), + forum: { + id: 'diversion', + glyph: '⏃', + label: 'test_forums.diversion' + }, + tags: [ + { + id: 'fish', + weight: 40 + }, + { + id: 'statue', + weight: 5 + } + ], + posts: [ + { + id: '413a74db-9473-4bac-8698-da9452c05854', + text: 'This is the first post', + created_at: Date.UTC(1999, 7, 1, 8, 8, 2, 111).valueOf(), + author: { + handle: 'past_user', + id: 'c76d3e51-76ac-4e84-a1b2-2eee9abd68b3' + } + }, + { + id: '821ff177-5250-406f-9431-1a8097b35430', + text: 'This response came later', + created_at: Date.UTC(2038, 1, 2, 3, 4, 6, 789).valueOf(), + author: { + handle: 'future_user', + id: 'cb9307cb-77e9-4c55-bbe7-dbbf88737358' + } + } + ] + }, + topicWithoutForum: { + id: '9715e9ee-0d63-4b50-b613-826ef2791728', + title: 'This topic, no forums', + ttl: 160 * 1000, + updated_at: Date.now(), + tags: [ + { + id: 'cauliflower', + weight: 33 + } + ], + posts: [] + } +}; + +describe('Topic component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.diversion': 'Diversion' + }); + }); + + beforeEach(() => { + + internals.results = render(Topic, { props: { + topic: internals.basicTopic + } }); + }); + + test('Should show the topic title', () => { + + expect(internals.results.getByText('I sure am a test topic')) + .toBeVisible(); + }); + test('Should display remaining time in readable format', () => { + + expect(internals.results.getByText(/2 minutes remaining/)) + .toBeVisible(); + }); + test('Remaining time should be a permalink to the topic', () => { + + expect(internals.results.getByText(/2 minutes remaining/).closest('a')) + .toHaveAttribute('href', '/t/b1a4f8d1-4d16-4872-b391-fda6a0e9012d'); + }); + + test('Should show text for all posts', () => { + + expect(internals.results.getByText('This is the first post')) + .toBeVisible(); + expect(internals.results.getByText('This response came later')) + .toBeVisible(); + }); + + test('Should send index and count to posts', () => { + + expect(internals.results.getByTitle('Post 1 of 2 by past_user')) + .toBeVisible(); + expect(internals.results.getByTitle('Post 2 of 2 by future_user')) + .toBeVisible(); + }); + + describe('Forum link', () => { + + test('Should show forum if the post has one', () => { + + expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/)) + .toBeVisible(); + }); + + test('Forum text should be a permalink to the forum', () => { + + expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/).closest('a')) + .toHaveAttribute('href', '/f/diversion'); + }); + + test('Should not show forum if the post doesn\'t have one', () => { + + cleanup(); + internals.results = render(Topic, { props: { + topic: internals.topicWithoutForum + } }); + + expect(internals.results.queryByText(/^\s*⏃\s*Diversion\s*$/)) + .toBe(null); + }); + }); + + describe('Tag listing', () => { + + test('Should show topic tags', () => { + + expect(internals.results.getByText('fish')) + .toBeVisible(); + expect(internals.results.getByText('fish')) + .toHaveTextContent('fish(40)'); + expect(internals.results.getByText('statue')) + .toBeVisible(); + expect(internals.results.getByText('statue')) + .toHaveTextContent('statue(5)'); + }); + + test('Tag text should be a permalink to the tag', () => { + + expect(internals.results.getByText('fish').closest('a')) + .toHaveAttribute('href', '/g/fish'); + expect(internals.results.getByText('statue').closest('a')) + .toHaveAttribute('href', '/g/statue'); + }); + }); +}); diff --git a/src/lib/components/topic_summary/topic_summary.svelte b/src/lib/components/topic_summary/topic_summary.svelte new file mode 100644 index 0000000..bfcf8ab --- /dev/null +++ b/src/lib/components/topic_summary/topic_summary.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + export let topic; + + import { _ } from 'svelte-i18n'; + import { readableTime } from '$lib/utils/readable_time'; + + $: remainingTime = topic.updated_at + topic.ttl - Date.now(); + $: remaining = readableTime(remainingTime); +</script> + +<li class="h-entry" title={$_('topic.title')}> + <span class="p-name"> + <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}"> + {topic.title} + </a></span> + <span class="topic-ttl">({$_('topic.remaining_time', { + values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) } + })}) + </span> +</li> + +<style> +</style> diff --git a/src/lib/components/topic_summary/topic_summary.test.ts b/src/lib/components/topic_summary/topic_summary.test.ts new file mode 100644 index 0000000..a4c46bf --- /dev/null +++ b/src/lib/components/topic_summary/topic_summary.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import TopicSummary from './topic_summary.svelte'; + +const internals = { + results: null +}; + +describe('Topic Summary component', () => { + + beforeEach(() => { + + internals.results = render(TopicSummary, { props: { + topic: { + id: 'ea2431c8-5c1c-4ed0-907a-45e012696ab8', + title: 'I sure am a test topic', + ttl: 160 * 1000, + updated_at: Date.now() + } + } }); + }); + + test('It should display the title', () => { + + expect(internals.results.getByText('I sure am a test topic')) + .toBeVisible(); + }); + + test('Topic title should be a permalink', () => { + + expect(internals.results.getByText('I sure am a test topic').closest('a')) + .toHaveAttribute('href', '/t/ea2431c8-5c1c-4ed0-907a-45e012696ab8'); + }); + + test('It should display remaining time in readable format', () => { + + expect(internals.results.getByText(/2 minutes remaining/)) + .toBeVisible(); + }); +}); |