From b079001cc52cb8330206dc474da168e4d36623ec Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 3 Apr 2026 16:03:16 -0500 Subject: [PATCH] feat: inactive contacts hidden in sidebar --- e2e/pages/contact.spec.ts | 12 +++++- e2e/pages/journal.spec.ts | 1 - e2e/playwright.config.ts | 90 +++++++++++++++++++-------------------- migrations/demo.sql | 4 +- src/web/contact/mod.rs | 22 +++++++--- src/web/mod.rs | 40 ++++++++++++++++- static/index.css | 12 +++++- 7 files changed, 123 insertions(+), 58 deletions(-) diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index 46af08e..a1e411d 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => { test('manual-freshen date is editable', async ({ page }) => { await page.getByRole('link', { name: /edit/i }).click(); - await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); + await expect(page.locator('input[name="manually_freshened_on"]')).toBeVisible(); }); test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => { @@ -38,7 +38,15 @@ test.skip("groups wrap nicely", async ({ page }) => { }); test('allow marking as inactive', async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + // TODO: this only works if there's no other comboboxes on the page :/ + await page.getByRole('combobox').selectOption('Inactive') + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + await expect(page.locator('#contacts-sidebar').getByText("Test Testerson")).not.toBeVisible(); }); test('allow exempting from stale', async ({ page }) => { @@ -50,7 +58,7 @@ test('stale list considers periodicity', async ({ page }) => { }); test('page title has contact primary name', async ({ page }) => { - await expect(page.title()).toContain("Test Testerson"); + expect(await page.title()).toContain("Test Testerson"); }); diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index f30efa3..fe9ecb9 100644 --- a/e2e/pages/journal.spec.ts +++ b/e2e/pages/journal.spec.ts @@ -43,7 +43,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await page.getByRole('button', { name: 'Add' }).nth(1).click(); await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click(); - console.log(await journal.innerHTML()); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); // delete an existing name diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 1065269..f1b1fab 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,72 +1,72 @@ import { defineConfig, devices } from '@playwright/test'; -import 'custom-expects'; +import './custom-expects'; // purposefully not using ??: we want to replace empty empty string with default const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; let addlConfig = { - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 0, }; let projects = [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, ]; const pfil = process.env.PROJECT_FILTER; if (pfil) { - if (pfil.startsWith('!')) { - projects = projects.filter(p => p.name !== pfil.slice(1)); - } else { - projects = projects.filter(p => p.name === pfil); - } + if (pfil.startsWith('!')) { + projects = projects.filter(p => p.name !== pfil.slice(1)); + } else { + projects = projects.filter(p => p.name === pfil); + } } /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './pages', - fullyParallel: true, - workers: 1, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: Boolean(process.env.CI), - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: BASE_URL, + testDir: './pages', + fullyParallel: true, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: Boolean(process.env.CI), + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: BASE_URL, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, - /* Configure projects for major browsers */ - projects, - ...addlConfig, + /* Configure projects for major browsers */ + projects, + ...addlConfig, }); diff --git a/migrations/demo.sql b/migrations/demo.sql index 17bda4c..8eb0d76 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -7,8 +7,8 @@ insert into names(contact_id, sort, name) values insert into groups(contact_id, name, slug) values (0, 'ABC', 'abc'); -insert into contacts(id, birthday) values - (1, 'April?'); +insert into contacts(id, birthday, active) values + (1, 'April?', false); insert into names(contact_id, sort, name) values (1, 0, 'Bazel Bagend'), (1, 1, 'Bazel'); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 88bd303..9f4bf8f 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -223,7 +223,9 @@ mod get { let mfresh_str = contact .manually_freshened_at .clone() - .map_or("".to_string(), |m| m.to_string()); + .map_or("".to_string(), |m| { + m.to_zoned(TimeZone::UTC).date().to_string() + }); let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) @@ -278,10 +280,20 @@ mod get { span .hint { code { "(yyyy|--)mmdd" } " or free text" } } label { "freshened" } - div x-data=(json!({ "date": mfresh_str })) { - input type="hidden" name="manually_freshened_at" x-model="date"; - span x-text="date.length ? date.split('T')[0] : '(never)'" {} - input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()"; + div x-data=(json!({ "date": mfresh_str, "stamp": "" })) x-init="today = () => (new Date().toISOString().split('T')[0])" { + input + type="hidden" + name="manually_freshened_at" + x-model="stamp"; + input + type="date" + name="manually_freshened_on" + x-model="date" + x-bind:max="today()" + x-on:input="stamp = new Date(date).toISOString()"; + + input type="button" value="Mark fresh now" x-on:click="date = today(); stamp = new Date().toISOString()"; + span .hint x-text="`max ${today()}`"; } label { "phone" } #phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) { diff --git a/src/web/mod.rs b/src/web/mod.rs index 6a6e141..a7f755d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -25,6 +25,7 @@ struct ContactLink { #[derive(Debug)] pub struct Layout { contact_links: Vec, + inactive_contact_links: Vec, user: User, } @@ -48,6 +49,20 @@ impl FromRequestParts for Layout { from contacts c left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 + and c.active = true + order by name asc", + ) + .fetch_all(&state.db(&user).pool) + .await?; + + let inactive_contact_links = sqlx::query_as!( + ContactLink, + "select c.id as contact_id, + coalesce(n.name, '(unnamed)') as name + from contacts c + left join names n on c.id = n.contact_id + where n.sort is null or n.sort = 0 + and c.active = false order by name asc", ) .fetch_all(&state.db(&user).pool) @@ -55,13 +70,19 @@ impl FromRequestParts for Layout { Ok(Layout { contact_links, + inactive_contact_links, user, }) } } impl Layout { - pub fn render(&self, title: impl AsRef, css: Option>, content: Markup) -> Markup { + pub fn render( + &self, + title: impl AsRef, + css: Option>, + content: Markup, + ) -> Markup { html! { (DOCTYPE) html { @@ -101,6 +122,23 @@ impl Layout { } } } + + @if !self.inactive_contact_links.is_empty() { + li .inactive { + details { + summary { "Inactive contacts" } + ul { + @for link in &self.inactive_contact_links { + li { + a href=(format!("/contact/{}", link.contact_id)) { + (link.name) + } + } + } + } + } + } + } } } main { diff --git a/static/index.css b/static/index.css index 03bf9da..5889146 100644 --- a/static/index.css +++ b/static/index.css @@ -8,7 +8,7 @@ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abb body { width: 100%; - min-height: 100vh; + height: 100vh; display: flex; flex-direction: column; align-items: center; @@ -35,6 +35,7 @@ section#content { display: flex; flex-direction: row; width: 100%; + height: 100%; @media only screen and (max-width: 650px) { position: relative; } @@ -44,6 +45,8 @@ section#content { display: flex; flex-direction: column; padding-right: 1em; + height: 100%; + overflow-y: auto; @media only screen and (max-width: 650px) { position: absolute; float: left; @@ -58,7 +61,7 @@ section#content { } } - ul { + & > ul { flex: 1; width: fit-content; background-color: var(--main-bg-color); @@ -79,12 +82,17 @@ section#content { border-bottom: none; } } + + li.inactive { + font-size: small; + } } main { display: flex; flex-direction: column; flex: 1; + overflow-y: auto; } .icon {