From 2b73ab3647e26c65e78daa3bc22ca677415a3e61 Mon Sep 17 00:00:00 2001 From: "J. Shane Kunkle" Date: Thu, 7 Nov 2024 11:18:19 -0500 Subject: [PATCH 01/67] New Playwright Test - can remove member WIP --- test/auth.test.ts | 23 +++++++++++++++++++++++ test/helpers/App.ts | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/test/auth.test.ts b/test/auth.test.ts index 53812fe4..1670b42c 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -75,6 +75,29 @@ test("uses an invitation to join as member", async ({ context }) => { await ritika.expect.toBeLoggedIn("Ritika Bhasker") }) +test("can remove member", async ({ context }) => { + const noise = await newBrowser(context) + await noise.createTeam("noise", `Noise team`) + + const herb = await newBrowser(context) + await herb.createTeam("herb", "DevResults") + + const invitationCode = await herb.invite("Shane Kunkle") + const shane = await newBrowser(context) + await shane.joinAsMember("shane", invitationCode) + await shane.expect.toBeLoggedIn("Shane Kunkle") + + // TODO seems like the smart thing to do is to create a new member - but i don't see a UI way to do that + // const invitationCode = await herb.invite("Foo Bar") + // const foobar = await newBrowser(context) + // await foobar.joinAsMember("foobar", invitationCode) + // await foobar.expect.toBeLoggedIn("Foo Bar") + await herb.deleteContact("shane") + // TODO test that shane isn't here anymore + // herb.expect.not.toseecon + // Q - will this persit shane being deleted in the xdev app? Seems like this should be a sandboxed change? +}) + test("sees new members when they join", async ({ context }) => { const herb = await newBrowser(context) await herb.createTeam("herb", "DevResults") diff --git a/test/helpers/App.ts b/test/helpers/App.ts index 39693318..4f40eff2 100644 --- a/test/helpers/App.ts +++ b/test/helpers/App.ts @@ -161,6 +161,14 @@ export class App { await this.pressButton("Yes") } + async deleteContact(userName: string) { + const contactRow = await this.getContactRow(userName) + const deleteButtonLink = contactRow.getByTitle("Remove member from team") + await expect(deleteButtonLink).toBeVisible() + // await deleteButtonLink.click() + // await this.pressButton("Yes") + } + async createDeviceInvitation() { await this.navigateTo("Settings") await this.navigateTo("Devices") From cdd2de364b71da3b7e48d9ef492e338fa3fc4a79 Mon Sep 17 00:00:00 2001 From: "J. Shane Kunkle" Date: Thu, 7 Nov 2024 11:48:54 -0500 Subject: [PATCH 02/67] Working test --- playwright.config.ts | 2 +- test/auth.test.ts | 10 ++-------- test/helpers/App.ts | 4 ++-- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 9f134391..e5594711 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ testMatch: "*.test.ts", /* tests fail if they take longer than this */ - timeout: 15_000, + timeout: 45_000, /* Run tests in files in parallel */ fullyParallel: true, diff --git a/test/auth.test.ts b/test/auth.test.ts index 1670b42c..5a4b9635 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -87,15 +87,9 @@ test("can remove member", async ({ context }) => { await shane.joinAsMember("shane", invitationCode) await shane.expect.toBeLoggedIn("Shane Kunkle") - // TODO seems like the smart thing to do is to create a new member - but i don't see a UI way to do that - // const invitationCode = await herb.invite("Foo Bar") - // const foobar = await newBrowser(context) - // await foobar.joinAsMember("foobar", invitationCode) - // await foobar.expect.toBeLoggedIn("Foo Bar") await herb.deleteContact("shane") - // TODO test that shane isn't here anymore - // herb.expect.not.toseecon - // Q - will this persit shane being deleted in the xdev app? Seems like this should be a sandboxed change? + await herb.reload() + await herb.expect.not.toSeeContact("Shane Kunkle") }) test("sees new members when they join", async ({ context }) => { diff --git a/test/helpers/App.ts b/test/helpers/App.ts index 4f40eff2..20eb7dee 100644 --- a/test/helpers/App.ts +++ b/test/helpers/App.ts @@ -165,8 +165,8 @@ export class App { const contactRow = await this.getContactRow(userName) const deleteButtonLink = contactRow.getByTitle("Remove member from team") await expect(deleteButtonLink).toBeVisible() - // await deleteButtonLink.click() - // await this.pressButton("Yes") + await deleteButtonLink.click() + await this.pressButton("Yes") } async createDeviceInvitation() { From 2b69963404007399d2407899a07b17db181a6d0d Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:14:18 +0100 Subject: [PATCH 03/67] remove stray console.log --- app/context/Auth/getSyncServer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/context/Auth/getSyncServer.ts b/app/context/Auth/getSyncServer.ts index b4a8f2fb..e366f646 100644 --- a/app/context/Auth/getSyncServer.ts +++ b/app/context/Auth/getSyncServer.ts @@ -1,6 +1,5 @@ const isDev = !import.meta.env.PROD const devPort = (import.meta.env.VITE_SYNC_SERVER_PORT as string) ?? "3030" -console.log("devPort", devPort) const httpProtocol = isDev ? "http:" : "https:" const wsProtocol = isDev ? "ws:" : "wss:" From 68a6e5652607586ab361f0e6962d3a402052dd83 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:14:32 +0100 Subject: [PATCH 04/67] by: simplify types --- app/lib/by.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/lib/by.ts b/app/lib/by.ts index ce912213..77291d69 100644 --- a/app/lib/by.ts +++ b/app/lib/by.ts @@ -6,13 +6,9 @@ * ``` */ export const by = - , K extends keyof T>(key: K) => + (key: K) => (a: T, b: T) => { - const aVal = a[key].toString() ?? "" - const bVal = b[key].toString() ?? "" + const aVal = String(a[key]) + const bVal = String(b[key]) return aVal.localeCompare(bVal) } - -type ConvertibleToString = { - toString(): string -} From e6f9dd04d9aaf855eb2d7f4bdac41bae1e2898c3 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:14:50 +0100 Subject: [PATCH 05/67] projects: no space after colon --- app/data/projects.ts | 116 ++++++++++++++--------------- app/lib/test/TimeEntryView.test.ts | 6 +- app/schema/Project.ts | 2 +- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/app/data/projects.ts b/app/data/projects.ts index f5a94c3c..f2615c30 100644 --- a/app/data/projects.ts +++ b/app/data/projects.ts @@ -3,72 +3,72 @@ import { Project } from "schema/Project" import tailwindColors from "tailwindcss/colors" const projectList = ` -Business: Contracts yes Contract negotiation & other back-and-forth -Business: Marketing Work on DevResults.com, writing blogs, creating materials, etc. -Business: Outreach sometimes Conferences, coffee, donuts, cold calls, etc -Business: Proposals yes Writing/editing proposals for specific prospects -DevOps: Azure migration -DevOps: CI configuration -DevOps: General Non-project-oriented DevOps work: server/VM/database configuration, etc. -Feature: Activity-Specific Stuff -Feature: Ag Grid conversions -Feature: API -Feature: Baselines -Feature: ContentObject Validation -Feature: Count First/Last -Feature: Count Unique Per Reporting Period -Feature: Dashboard -Feature: Data Tables -Feature: DevIndicators -Feature: Diagnostics -Feature: Dropbox integration -Feature: EditGrid -Feature: Enterprise -Feature: Frameworks Index 2022-S01 etc -Feature: GDPR -Feature: Google Drive integration -Feature: Ground Truth -Feature: IATI -Feature: Indexify -Feature: Instance Export -Feature: Internal Tooling Includes instance bootstrapper features, DevResults CLI, etc. -Feature: Localization Includes managing LanguageStrings, getting translations, etc. -Feature: Matrix -Feature: Metadata visualization -Feature: Notifications -Feature: Partner Permissions 2022 sprint 8 and associated work -Feature: PowerBI -Feature: Project X -Feature: Pseudonyms -Feature: Public Site -Feature: RDTs -Feature: Self-serve -Feature: Single Sign-On , and supporting clients -Feature: Standard RP Picker -Feature: Survey123 -Feature: SurveyCTO -Feature: Too small to name But you can name it in the comments! -Feature: WorldAdminDivisions update +Business:Contracts yes Contract negotiation & other back-and-forth +Business:Marketing Work on DevResults.com, writing blogs, creating materials, etc. +Business:Outreach sometimes Conferences, coffee, donuts, cold calls, etc +Business:Proposals yes Writing/editing proposals for specific prospects +DevOps:Azure migration +DevOps:CI configuration +DevOps:General Non-project-oriented DevOps work:server/VM/database configuration, etc. +Feature:Activity-Specific Stuff +Feature:Ag Grid conversions +Feature:API +Feature:Baselines +Feature:ContentObject Validation +Feature:Count First/Last +Feature:Count Unique Per Reporting Period +Feature:Dashboard +Feature:Data Tables +Feature:DevIndicators +Feature:Diagnostics +Feature:Dropbox integration +Feature:EditGrid +Feature:Enterprise +Feature:Frameworks Index 2022-S01 etc +Feature:GDPR +Feature:Google Drive integration +Feature:Ground Truth +Feature:IATI +Feature:Indexify +Feature:Instance Export +Feature:Internal Tooling Includes instance bootstrapper features, DevResults CLI, etc. +Feature:Localization Includes managing LanguageStrings, getting translations, etc. +Feature:Matrix +Feature:Metadata visualization +Feature:Notifications +Feature:Partner Permissions 2022 sprint 8 and associated work +Feature:PowerBI +Feature:Project X +Feature:Pseudonyms +Feature:Public Site +Feature:RDTs +Feature:Self-serve +Feature:Single Sign-On , and supporting clients +Feature:Standard RP Picker +Feature:Survey123 +Feature:SurveyCTO +Feature:Too small to name But you can name it in the comments! +Feature:WorldAdminDivisions update Out Overhead General meetings, company process stuff, finance stuff, HR, onboarding, learning… Security All things security -Support: API mostly Any help provided to helping users access and use the API -Support: External tooling Providing support for "other tools" outside of DevResults (e.g. PowerBI) -Support: Ongoing mostly Includes engineering support to individual clients (but not bug fixing) -Support: Scaling training & help Videos, help materials, etc. -Support: Setup yes Everything before the instance goes live -Support: Training sometimes Training dedicated to one specific client -Tech wealth: Bug fixin Probably mostly on call. Note feature in comments if it's a major feature fix. -Tech wealth: Other Catch-all for general improvements to the codebase -Tech wealth: Optimization Work to improve performance within the app -Tech wealth: TypeScript -Tech wealth: Webpack +Support:API mostly Any help provided to helping users access and use the API +Support:External tooling Providing support for "other tools" outside of DevResults (e.g. PowerBI) +Support:Ongoing mostly Includes engineering support to individual clients (but not bug fixing) +Support:Scaling training & help Videos, help materials, etc. +Support:Setup yes Everything before the instance goes live +Support:Training sometimes Training dedicated to one specific client +Tech wealth:Bug fixin Probably mostly on call. Note feature in comments if it's a major feature fix. +Tech wealth:Other Catch-all for general improvements to the codebase +Tech wealth:Optimization Work to improve performance within the app +Tech wealth:TypeScript +Tech wealth:Webpack Thought Leadership ` .trim() .split("\n") .map(line => { const [fullCode, requiresClient, description] = line.trim().split("\t") - const [code, subCode] = fullCode.split(/:\s+/).map(s => s.trim().replaceAll(" ", "-")) + const [code, subCode] = fullCode.split(/:\s*/).map(s => s.trim().replaceAll(" ", "-")) return { code, subCode, diff --git a/app/lib/test/TimeEntryView.test.ts b/app/lib/test/TimeEntryView.test.ts index 10eb4a61..bf39489c 100644 --- a/app/lib/test/TimeEntryView.test.ts +++ b/app/lib/test/TimeEntryView.test.ts @@ -45,7 +45,7 @@ describe("TimeEntryView", () => { }, "description": "", "duration": 390, - "input": "Feature:Metadata-visualization 390mins", + "input": "6:30 #Feature:Metadata-visualization", "project": { "code": "Feature", "color": "#f59e0b", @@ -63,7 +63,7 @@ describe("TimeEntryView", () => { }, "description": "", "duration": 30, - "input": "Feature:DevIndicators 30mins", + "input": "0:30 #Feature:DevIndicators", "project": { "code": "Feature", "color": "#f59e0b", @@ -109,7 +109,7 @@ describe("TimeEntryView", () => { }, "description": "", "duration": 360, - "input": "Support:Ongoing 360mins", + "input": "6:00 #Support:Ongoing", "project": { "code": "Support", "color": "#10b981", diff --git a/app/schema/Project.ts b/app/schema/Project.ts index c29e5738..4ea9db80 100644 --- a/app/schema/Project.ts +++ b/app/schema/Project.ts @@ -30,7 +30,7 @@ export class Project extends S.Class("Project")({ } export const makeFullCode = (code: string, subCode?: string) => - subCode ? `${code}: ${subCode}` : code + subCode ? `${code}:${subCode}` : code export type ProjectEncoded = typeof Project.Encoded /** From f67f06bb3a32246d693c38397567bb27895016e2 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:15:57 +0100 Subject: [PATCH 06/67] popover: leave styling (shadow etc) to implementation --- app/ui/shadcn/popover.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/ui/shadcn/popover.tsx b/app/ui/shadcn/popover.tsx index 97f7b790..50ec78e7 100644 --- a/app/ui/shadcn/popover.tsx +++ b/app/ui/shadcn/popover.tsx @@ -3,9 +3,7 @@ import { cx } from "lib/cx" import * as React from "react" const Popover = PopoverPrimitive.Root - const PopoverTrigger = PopoverPrimitive.Trigger - const PopoverAnchor = PopoverPrimitive.Anchor const PopoverArrow = PopoverPrimitive.Arrow @@ -19,7 +17,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cx( - "z-50 w-72 rounded-md border p-4 shadow-md outline-none", + "z-50 ", "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 ", "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "data-[side=bottom]:slide-in-from-top-2", From 2b0bbbe391a0bec7b27557de840d9c139798f96a Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:16:08 +0100 Subject: [PATCH 07/67] add AutocompleteMenu component --- app/ui/AutocompleteMenu.tsx | 130 ++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 app/ui/AutocompleteMenu.tsx diff --git a/app/ui/AutocompleteMenu.tsx b/app/ui/AutocompleteMenu.tsx new file mode 100644 index 00000000..e96a6edb --- /dev/null +++ b/app/ui/AutocompleteMenu.tsx @@ -0,0 +1,130 @@ +import type { Collection } from "context/Database/Collection" +import type { CollectionItem, ItemOf, StringKeyOf } from "context/Database/types" +import { cx } from "lib/cx" +import { $ } from "lib/Effect" +import { Keys } from "lib/keys" +import { useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import type { RootEncoded } from "schema/Root" + +const { enter, up, down } = Keys + +export const AutocompleteMenu = ({ items, onSelect }: Props) => { + if (items.length === 0) return null + + const [selectedIndex, setSelectedIndex] = useState(0) + + useHotkeys( + [enter, up, down], + (e, { keys = [] }) => { + const key = keys.join("") + if (key === enter) { + e.preventDefault() // Prevent form submission + onSelect(items[selectedIndex]) + } + + if (key === up) setSelectedIndex(i => Math.max(i - 1, 0)) + if (key === down) setSelectedIndex(i => Math.min(i + 1, items.length - 1)) + }, + { enableOnFormTags: true }, + ) + + return ( + <> + {items.map((item, index) => ( +
{ + e.preventDefault() // Prevent textarea blur + onSelect(item) + }} + > + {item} +
+ ))} + + ) +} + +type Props = { + items: string[] + onSelect: (selection: string) => void +} + +// HELPERS + +/** + * Finds an autocomplete query substring given the text in an input and the current cursor + * position within the input. + **/ +export const findAutocompleteQuery = ( + /** The full text contents of the input */ + text: string, + /** The position of the cursor within the input */ + position: number, + /** The trigger definitions */ + triggers: AutocompleteTrigger[], +): AutocompleteQuery | undefined => { + // Find start of current word + let start = position + while (start > 0 && !/\s/.test(text[start - 1])) start-- + + // Find end of current word + let end = position + while (end < text.length && !/\s/.test(text[end])) end++ + + // Extract word at cursor + const word = text.slice(start, end) + + for (const { type, trigger } of triggers) + if (word.startsWith(trigger)) { + const query = word.slice(1) // remove the trigger character at the beginning + return { type, trigger, start, end, query } + } +} + +/** + * Given a query and a set of autocomplete definitions, returns an array of strings to show in an + * autocomplete menu. + */ +export const getAutocompleteItems = < + Name extends keyof RootEncoded, + Item extends CollectionItem = ItemOf, +>( + { query, trigger }: AutocompleteQuery, + autocompleteModes: Array>, +) => { + const mode = autocompleteModes.find(m => m.trigger === trigger) + if (!mode) return [] + + const { collection, property } = mode + + return $(collection.all()) + .map(item => String(item[property])) + .filter(value => value.toLowerCase().includes(query.toLowerCase())) + .sort() +} + +export type AutocompleteTrigger = { + type: string + trigger: string +} + +export type AutocompleteMode< + Name extends keyof RootEncoded = any, + Item extends CollectionItem = ItemOf, + C = Collection, +> = AutocompleteTrigger & { + property: StringKeyOf + collection: C +} + +export type AutocompleteQuery = AutocompleteTrigger & { + start: number + end: number + query: string +} From 69abe6f07e833860ca68a7fcfb1833c30a423630 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Fri, 17 Jan 2025 19:16:27 +0100 Subject: [PATCH 08/67] TimeEntryInput: add autocomplete --- app/ui/TimeEntryInput.tsx | 177 +++++++++++++++++++++++++++----------- 1 file changed, 128 insertions(+), 49 deletions(-) diff --git a/app/ui/TimeEntryInput.tsx b/app/ui/TimeEntryInput.tsx index 18e24a10..376687a4 100644 --- a/app/ui/TimeEntryInput.tsx +++ b/app/ui/TimeEntryInput.tsx @@ -4,13 +4,20 @@ import { NO_OP } from "lib/constants" import { cx } from "lib/cx" import { $, E } from "lib/Effect" import { Keys } from "lib/keys" -import { useEffect, useState } from "react" +import { useEffect, useState, type ChangeEvent, type KeyboardEvent } from "react" import { useHotkeys } from "react-hotkeys-hook" import { ProvidedClients, type ClientCollection } from "schema/ClientCollection" import type { Contact } from "schema/Contact" import { parseTimeEntry } from "schema/lib/parseTimeEntry" import { ProvidedProjects, type ProjectCollection } from "schema/ProjectCollection" import type { TimeEntry } from "schema/TimeEntry" +import { + AutocompleteMenu, + findAutocompleteQuery, + getAutocompleteItems, + type AutocompleteMode, + type AutocompleteQuery, +} from "./AutocompleteMenu" const { enter, escape, up, down, left, right } = Keys @@ -22,7 +29,7 @@ export const TimeEntryInput = ({ content, index, isFocused = false, - self, + self: { id: contactId }, date, projects, clients, @@ -33,17 +40,22 @@ export const TimeEntryInput = ({ onCommit = NO_OP, onDiscard = NO_OP, }: Props) => { - // the content of the time entry while editing + const autocompleteModes: AutocompleteMode[] = [ + { type: "PROJECT", trigger: "#", collection: projects, property: "fullCode" }, + { type: "CLIENT", trigger: "@", collection: clients, property: "code" }, + ] as const + + // the content of the entry while editing const [newContent, setNewContent] = useState(content) // errors detected on the input const [errors, setErrors] = useState([]) + const showError = errors.length > 0 + const errorMessageId = `time-entry-error-${date.toString()}` useEffect(() => { - if (isFocused) { - // select the content when entering focus - input.current?.select() - } + // select the content when entering focus + if (isFocused) textarea.current?.select() }, [isFocused]) useEffect(() => { @@ -51,10 +63,57 @@ export const TimeEntryInput = ({ setNewContent(content) }, [content]) - const errorMessageId = `time-entry-error-${date.toString()}` + // if the user is currently typing an autocomplete query, this will contain + // the query and information about what to do with it + const [query, setQuery] = useState(undefined) + + const autocompleteItems = query ? getAutocompleteItems(query, autocompleteModes) : [] + + // If the only autocomplete item is the current query, we've either typed out a valid + // item by hand, or we just selected it. In either case, we should not show the autocomplete. + const autocompleteItemSelected = + query !== undefined && + query.query.length > 0 && + autocompleteItems.length === 1 && + autocompleteItems[0] === query.query - const contactId = self.id + // show the autocomplete if the user is typing a query and there are items to show + const showAutocomplete = + query !== undefined && // + isFocused && + autocompleteItems.length > 0 && + !autocompleteItemSelected + // bind textarea to hotkeys + const textarea = useHotkeys( + [enter, escape, up, down, left, right], + (e, { keys = [] }) => { + const key = keys.join("") + if (!textarea.current) return + const { value, selectionStart } = textarea.current + + if (key === escape) { + setNewContent(content) // restore the original content + setErrors([]) + onDiscard() + } else if (query) { + // let the autocomplete handle arrow keys and enter + } else if (key === up && selectionStart === 0) { + onFocusPrev() + } else if (key === down && selectionStart === value.length) { + onFocusNext() + } else if (key === enter) { + e.preventDefault() + commit(textarea.current.textContent ?? "") + } + }, + { enableOnFormTags: true }, + ) + + /** + * Given an input string, attempts to parse it into a time entry. + * Returns an effect. + */ const parse = (input: string) => parseTimeEntry({ contactId, date, input }).pipe( // rewrite some error messages @@ -67,6 +126,11 @@ export const TimeEntryInput = ({ E.provideService(ProvidedClients, clients), ) + /** + * Given a (potentially multiline) input string, attempts to parse into one or more time entries. + * Records any errors in states, and if there are none commits the entries. If the input is empty, + * the entry is destroyed. + */ const commit = (content: string) => { content = content.trim() if (content.length === 0) { @@ -86,58 +150,73 @@ export const TimeEntryInput = ({ } } - const input = useHotkeys( - [enter, escape, up, down, left, right], - (e, { keys = [] }) => { - const key = keys.join("") - if (!input.current) return - const { value, selectionStart } = input.current - if (key === escape) { - setNewContent(content) // restore the original content - setErrors([]) - onDiscard() - } else if (key === up && selectionStart === 0) { - onFocusPrev() - } else if (key === down && selectionStart === value.length) { - onFocusNext() - } else if (key === enter) { - e.preventDefault() - commit(input.current.textContent ?? "") - } - }, - { enableOnFormTags: true }, - ) - return ( - 0}> + + {/* INPUT TEXTBOX */}