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:" diff --git a/app/data/projects.ts b/app/data/projects.ts index f5a94c3c..54fd7061 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/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 -} 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/lib/test/findAutocompleteQuery.test.ts b/app/lib/test/findAutocompleteQuery.test.ts new file mode 100644 index 00000000..7edde8f9 --- /dev/null +++ b/app/lib/test/findAutocompleteQuery.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest" +import { + findAutocompleteQuery, + type AutocompleteState, + type AutocompleteTrigger, +} from "ui/AutocompleteMenu" +import { assert } from "lib/assert" + +const autocompleteTriggers: AutocompleteTrigger[] = [ + { type: "PROJECT", trigger: "#" }, + { type: "CLIENT", trigger: "@" }, +] as const + +// the `|` character represents the cursor position +const cases: TestCase[] = [ + { + name: "@ trigger at end of word", + input: "#support @usaid|", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "# trigger at end of word", + input: "#support|", + expected: { trigger: "#", query: "support", start: 0, end: 8 }, + }, + { + name: "trigger in middle of word", + input: "#support @usa|id pizza", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "trigger at start of word", + input: "#support |@usaid pizza", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "cursor not in trigger word", + input: "he|llo @usaid", + expected: undefined, + }, + { + name: "empty string", + input: "|", + expected: undefined, + }, + { + name: "spaces around trigger word", + input: " @us|aid ", + expected: { trigger: "@", query: "usaid", start: 3, end: 9 }, + }, +] + +/** + * Takes a string containing a cursor marker `|` and returns the text without the cursor marker + * along with the position of the cursor + */ +const extractCursor = (text: string): { text: string; position: number } => { + const position = text.indexOf("|") + if (position === -1) throw new Error("No cursor marker found in test input") + return { + text: text.replace("|", ""), + position, + } +} + +describe("findAutocompleteTriggerAtCursor", () => { + test.each(cases)("$name", ({ input, expected }) => { + const { text, position } = extractCursor(input) + const result = findAutocompleteQuery(text, position, autocompleteTriggers) + if (expected === undefined) { + expect(result).toBeUndefined() + } else { + assert(result !== undefined) + const { trigger, query, start, end } = result + expect({ trigger, query, start, end }).toEqual(expected) + } + }) +}) + +type TestCase = { + name: string + input: string // includes `|` marker + expected: Pick | undefined +} 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 /** diff --git a/app/schema/lib/parseProject.ts b/app/schema/lib/parseProject.ts index 139059f5..08848e16 100644 --- a/app/schema/lib/parseProject.ts +++ b/app/schema/lib/parseProject.ts @@ -51,11 +51,11 @@ export const parseProject = (input: string) => class MultipleProjectsError // extends Data.TaggedError("parseProject/MultipleProjects")<{ input: string }> { - message = `An entry can only have one #project code.` + message = `An entry can only have one project code.` } export class NoProjectError // extends Data.TaggedError("parseProject/NoProject")<{ input: string }> { - message = `You need to include a #project code.` + message = `You need to include a project code.` } diff --git a/app/schema/lib/parseTimeEntry.ts b/app/schema/lib/parseTimeEntry.ts index 996f8a36..94d68aee 100644 --- a/app/schema/lib/parseTimeEntry.ts +++ b/app/schema/lib/parseTimeEntry.ts @@ -43,5 +43,5 @@ export class ProjectRequiresClientError // project: Project }> { - message = `For ${this.project.fullCode}, you need to specify a @client` + message = `For ${this.project.fullCode}, you need to specify a client` } diff --git a/app/ui/AutocompleteMenu.tsx b/app/ui/AutocompleteMenu.tsx new file mode 100644 index 00000000..1868aa04 --- /dev/null +++ b/app/ui/AutocompleteMenu.tsx @@ -0,0 +1,138 @@ +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, id }: Props) => { + if (items.length === 0) return null + + const [selectedIndex, setSelectedIndex] = useState(0) + + useHotkeys( + [enter, up, down], + (e, { keys = [] }) => { + e.preventDefault() // Prevent form submission + e.stopImmediatePropagation() + const key = keys.join("") + if (key === enter) 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 = { + id: string + 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[], +): AutocompleteState | 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 }: AutocompleteState, + 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((a, b) => { + // list matches that start with the query first, otherwise sort alphabetically + const aStartsWith = a.toLowerCase().startsWith(query.toLowerCase()) + const bStartsWith = b.toLowerCase().startsWith(query.toLowerCase()) + if (aStartsWith && !bStartsWith) return -1 + if (!aStartsWith && bStartsWith) return 1 + return a.localeCompare(b) + }) +} + +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 AutocompleteState = AutocompleteTrigger & { + start: number + end: number + query: string +} diff --git a/app/ui/AutocompleteTextarea.tsx b/app/ui/AutocompleteTextarea.tsx new file mode 100644 index 00000000..7469254d --- /dev/null +++ b/app/ui/AutocompleteTextarea.tsx @@ -0,0 +1,173 @@ +import { Popover, PopoverAnchor, PopoverContent } from "@ui/popover" +import { NO_OP } from "lib/constants" +import { cx } from "lib/cx" +import { forwardRef, useEffect, useRef, useState, type ForwardedRef } from "react" +import { Caret } from "textarea-caret-ts" +import { + AutocompleteMenu, + findAutocompleteQuery, + getAutocompleteItems, + type AutocompleteMode, + type AutocompleteState, +} from "./AutocompleteMenu" + +export const AutocompleteTextarea = forwardRef( + ( + { + id, + value, + modes, + className, + onChange = NO_OP, + onBlur = NO_OP, + onFocus = NO_OP, + onOpen = NO_OP, + onClose = NO_OP, + ...props + }: Props, + forwardedRef: ForwardedRef, + ) => { + const [isFocused, setIsFocused] = useState(false) + + // Create local ref to ensure we always have a ref object + const localRef = useRef(null) + // Use forwarded ref if provided, otherwise fall back to local ref + const ref = (forwardedRef ?? localRef) as React.RefObject + + const autocompleteMenuId = `${id}-autocomplete-menu` + + // if the user is currently typing an autocomplete query, this will contain + // the query and information about what to do with it + const [queryState, setQueryState] = useState() + const autocompleteItems = queryState ? getAutocompleteItems(queryState, modes) : [] + const [autocompletePosition, setAutocompletePosition] = useState({ x: 0, y: 0 }) + + // If the first or 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 justAutocompleted = + queryState !== undefined && + autocompleteItems[0].toLowerCase() === queryState.query.toLowerCase() + + // show the autocomplete if the user is typing a query and there are items to show + const showAutocomplete = + queryState !== undefined && // + isFocused && + !justAutocompleted + + useEffect(() => { + if (showAutocomplete) onOpen() + else onClose() + }, [showAutocomplete]) + + return ( + + +