@anchrd/intel 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
10
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Who to give this card to (#700, D70).
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **The list is the board's, not the installation's.** Only people who can actually open this
|
|
17
|
+
* board are offered, so handing somebody a card never also hands them a reason to ask why they
|
|
18
|
+
* cannot see it.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **Under two characters the answer is empty on purpose**, and the wording says so rather than
|
|
21
|
+
* showing "nothing found": a picker that says "no matches" for one typed letter teaches the reader
|
|
22
|
+
* that the person is not there, which is the opposite of the truth.
|
|
23
|
+
*/
|
|
24
|
+
export function AssigneePicker({
|
|
25
|
+
boardId,
|
|
26
|
+
current,
|
|
27
|
+
onPick,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
boardId: string;
|
|
31
|
+
current: string | null;
|
|
32
|
+
onPick(assigneeId: string | null): void;
|
|
33
|
+
// ⚠️ THE ANCHOR, and it is not optional decoration (#723). Radix positions a menu against its
|
|
34
|
+
// trigger; the first version of this component had none, and the menu never appeared. Every test
|
|
35
|
+
// stayed green, because jsdom has no layout and Testing Library finds a portal wherever it sits.
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}) {
|
|
38
|
+
const i18n = useI18n();
|
|
39
|
+
const { data } = useIntelRouterContext();
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
/**
|
|
42
|
+
* ⚠️ **The query is gated on being OPEN, and that is not an optimisation** (#757). Since the empty
|
|
43
|
+
* query became a real question, an ungated `useQuery` fires the moment the component mounts — and
|
|
44
|
+
* this component wraps every assignee circle on the screen. A board with forty cards would open
|
|
45
|
+
* forty requests for a menu nobody has touched.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ **Uncontrolled, since #750.** This used to accept an `open` prop for the plus menu, which
|
|
48
|
+
* opened the picker without a trigger of its own. That entry is gone — the circle is always on the
|
|
49
|
+
* row and opens the same menu — and the prop went with it rather than staying as a second way in
|
|
50
|
+
* that nothing drives and nothing keeps in step.
|
|
51
|
+
*/
|
|
52
|
+
const [shown, setShown] = useState(false);
|
|
53
|
+
const trimmed = query.trim();
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ **Enabled for an EMPTY query too, since #757.** Nothing typed asks a different question than
|
|
56
|
+
* one letter: it names the people who reach this board, a short set this installation already
|
|
57
|
+
* knows. One letter stays refused, because that IS a search and a bad one.
|
|
58
|
+
*/
|
|
59
|
+
const search = useQuery({
|
|
60
|
+
queryKey: ["board-assignee-search", boardId, trimmed],
|
|
61
|
+
enabled: shown && (trimmed.length === 0 || trimmed.length >= 2),
|
|
62
|
+
queryFn: async () => await data.searchBoardAssignees(boardId, trimmed),
|
|
63
|
+
});
|
|
64
|
+
const found = search.data?.items ?? [];
|
|
65
|
+
const more = search.data?.more ?? 0;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<DropdownMenu onOpenChange={setShown}>
|
|
69
|
+
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
|
70
|
+
<DropdownMenuContent align="start" className="w-64">
|
|
71
|
+
<div className="p-1">
|
|
72
|
+
<input
|
|
73
|
+
// biome-ignore lint/a11y/noAutofocus: the menu opens on a deliberate click, never on load
|
|
74
|
+
autoFocus
|
|
75
|
+
value={query}
|
|
76
|
+
aria-label={i18n.t("board.assignee.search")}
|
|
77
|
+
placeholder={i18n.t("board.assignee.search")}
|
|
78
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
79
|
+
onKeyDown={(event) => event.key === "Escape" && setShown(false)}
|
|
80
|
+
className="w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
{current === null ? null : (
|
|
84
|
+
<>
|
|
85
|
+
<DropdownMenuItem
|
|
86
|
+
onSelect={() => {
|
|
87
|
+
onPick(null);
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{i18n.t("board.assignee.clear")}
|
|
91
|
+
</DropdownMenuItem>
|
|
92
|
+
<DropdownMenuSeparator />
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
{/* ⚠️ Only for the ONE-character case. An empty query now answers with suggestions, and
|
|
96
|
+
telling somebody to keep typing while showing them names would be a hint that contradicts
|
|
97
|
+
the list under it. */}
|
|
98
|
+
{trimmed.length === 1 ? (
|
|
99
|
+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
|
|
100
|
+
{i18n.t("board.assignee.keepTyping")}
|
|
101
|
+
</p>
|
|
102
|
+
) : null}
|
|
103
|
+
{trimmed.length !== 1 && !search.isPending && found.length === 0 ? (
|
|
104
|
+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
|
|
105
|
+
{i18n.t("board.assignee.nobody")}
|
|
106
|
+
</p>
|
|
107
|
+
) : null}
|
|
108
|
+
{found.map((person) => (
|
|
109
|
+
<DropdownMenuItem
|
|
110
|
+
key={person.id}
|
|
111
|
+
onSelect={() => {
|
|
112
|
+
onPick(person.id);
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<span className="truncate">{person.name}</span>
|
|
116
|
+
{/* ⚠️ The address disambiguates two colleagues who share a first name. It is the
|
|
117
|
+
second line, not the first: a picker reads by NAME, and an address in the same
|
|
118
|
+
weight would make every row look like a form field. */}
|
|
119
|
+
<span className="ml-auto truncate text-muted-foreground text-xs">{person.email}</span>
|
|
120
|
+
</DropdownMenuItem>
|
|
121
|
+
))}
|
|
122
|
+
{/* ⚠️ The cap says so rather than hiding. Three names that look like the whole list teach
|
|
123
|
+
the reader that nobody else exists, which is the one thing this list must not say. */}
|
|
124
|
+
{more === 0 ? null : (
|
|
125
|
+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
|
|
126
|
+
{i18n.t("board.assignee.more", { count: more })}
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</DropdownMenuContent>
|
|
130
|
+
</DropdownMenu>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
|
+
import { useResolvedName } from "@/resolved-names/resolved-names.tsx";
|
|
3
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What to call whoever a card is for.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **One rule, two surfaces.** The table draws the name in a cell, the open card draws it in a
|
|
9
|
+
* circle, and both must answer the same way — `useUserName` resolves only the reader themselves, so
|
|
10
|
+
* for anybody else the honest answer is "a user account" and never the principal id the row carries
|
|
11
|
+
* (`user-name.ts`, #258). Written twice, the two would drift, and the drift would put an id on one
|
|
12
|
+
* of the two screens.
|
|
13
|
+
*/
|
|
14
|
+
export function useAssigneeLabel(id: string | null): string {
|
|
15
|
+
const i18n = useI18n();
|
|
16
|
+
// ⚠️ TWO sources, and the order matters. `useUserName` answers for the reader themselves out of
|
|
17
|
+
// the session the shell already holds, without a request; the board's resolved map answers for
|
|
18
|
+
// everybody else. Asking the map first would make the reader's own name depend on a round trip
|
|
19
|
+
// that has no reason to exist, and it would leave them nameless on any screen without a provider.
|
|
20
|
+
const own = useUserName(id);
|
|
21
|
+
const resolved = useResolvedName(id);
|
|
22
|
+
if (id === null) return i18n.t("board.unassigned");
|
|
23
|
+
// ⚠️ The fallback stays what it was: a person we cannot name is "a user account", never the
|
|
24
|
+
// principal id the row carries (#258). Names arriving is an improvement on this line, not a
|
|
25
|
+
// replacement for the rule underneath it.
|
|
26
|
+
return own ?? resolved ?? i18n.t("node.someUser");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The three tones the access summary already uses for its circles, so an assignee looks like the
|
|
31
|
+
* same kind of thing as a person on the share screen (#724).
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ **Chosen by the ID, not by a position.** The access summary can count places in a row; a card
|
|
34
|
+
* stands alone, and there is no row to count in. Hashing the id means the same person keeps the same
|
|
35
|
+
* colour on every card and every board, which is the only property that makes a colour worth having
|
|
36
|
+
* here — a colour that moves is noise.
|
|
37
|
+
*/
|
|
38
|
+
const PERSON_TONES = [
|
|
39
|
+
"bg-muted text-muted-foreground",
|
|
40
|
+
"bg-accent text-accent-foreground",
|
|
41
|
+
"bg-secondary text-secondary-foreground",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export function personTone(id: string): string {
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (const character of id) sum = (sum + character.charCodeAt(0)) % 4096;
|
|
47
|
+
return PERSON_TONES[sum % PERSON_TONES.length] ?? PERSON_TONES[0];
|
|
48
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { UserRound } from "lucide-react";
|
|
2
|
+
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
3
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { initials } from "@/user-name/user-name.ts";
|
|
6
|
+
import { personTone, useAssigneeLabel } from "./board-assignee.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The circle for whoever a card is for, and the way to give it to somebody else.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
|
|
12
|
+
* would drift, and the drift would be an accessibility bug on exactly one of them.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
|
|
15
|
+
* summary, the field stays `assigneeId`. A row of circles would imply a second data model.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **Drawn even for nobody, since #724** — and that reverses #692 on purpose rather than by
|
|
18
|
+
* accident. A text chip saying "nobody yet" was a placeholder for an absence and earned no room. An
|
|
19
|
+
* empty circle is not that: it is the one control on the row that says the card COULD be given to
|
|
20
|
+
* somebody, and the card nobody owns is exactly the one that needs it.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
|
|
23
|
+
* removes an assignment on the same click somebody uses to change one is a control that
|
|
24
|
+
* destroys work on a misclick. Removing lives inside the menu, where it says what it does.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
27
|
+
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
28
|
+
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
29
|
+
* the control says it too. That is the trap in `packages/intel/ui/CLAUDE.md`.
|
|
30
|
+
*/
|
|
31
|
+
export function AssigneeChip({
|
|
32
|
+
id,
|
|
33
|
+
boardId,
|
|
34
|
+
onPick,
|
|
35
|
+
}: {
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ `null` DRAWS, it does not skip (#724). An empty circle with a person in it is the one place
|
|
38
|
+
* on the row that says "this could be given to somebody" — and without it there is nothing to
|
|
39
|
+
* press on a card nobody owns, which is exactly the card that needs an owner.
|
|
40
|
+
*/
|
|
41
|
+
id: string | null;
|
|
42
|
+
boardId: string;
|
|
43
|
+
onPick(assigneeId: string | null): void;
|
|
44
|
+
}) {
|
|
45
|
+
const i18n = useI18n();
|
|
46
|
+
const label = useAssigneeLabel(id);
|
|
47
|
+
return (
|
|
48
|
+
<AssigneePicker boardId={boardId} current={id} onPick={onPick}>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label={
|
|
52
|
+
id === null
|
|
53
|
+
? i18n.t("board.assignSomebody")
|
|
54
|
+
: i18n.t("board.changeAssignee", { name: label })
|
|
55
|
+
}
|
|
56
|
+
title={label}
|
|
57
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
58
|
+
>
|
|
59
|
+
<span
|
|
60
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
61
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
62
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
63
|
+
// look as though the name were covered here rather than on the button.
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className={cn(
|
|
66
|
+
"flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background",
|
|
67
|
+
id === null ? "border border-dashed text-muted-foreground" : personTone(id),
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{id === null ? <UserRound aria-hidden="true" className="size-4" /> : initials(label)}
|
|
71
|
+
</span>
|
|
72
|
+
</button>
|
|
73
|
+
</AssigneePicker>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
/**
|
|
3
|
+
* A value on a card, and the way to take it off again.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw these; written twice
|
|
6
|
+
* they drift in size, in shape and — the expensive one — in what they are called.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **The name says the value AND what pressing it does.** A button called `Zebra` that quietly
|
|
9
|
+
* clears a dependency is one nobody can predict; a name that drops the value is the other half of
|
|
10
|
+
* the mistake, because somebody speaking the visible words has to be able to reach the control by
|
|
11
|
+
* them (WCAG 2.5.3). Value first, then the consequence.
|
|
12
|
+
*/
|
|
13
|
+
export function BoardChip({
|
|
14
|
+
label,
|
|
15
|
+
action,
|
|
16
|
+
onClick,
|
|
17
|
+
tone = "outline",
|
|
18
|
+
}: {
|
|
19
|
+
label: string;
|
|
20
|
+
action: string;
|
|
21
|
+
onClick(): void;
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ **`inverted` is for labels and nothing else** (#724). Status, dates and the assignee say what
|
|
24
|
+
* a card IS; a label is what somebody chose to call it, and the row needs to tell the two apart at
|
|
25
|
+
* a glance. Filling every chip would take that difference away again.
|
|
26
|
+
*/
|
|
27
|
+
tone?: "outline" | "inverted";
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
aria-label={`${label}, ${action}`}
|
|
33
|
+
onClick={onClick}
|
|
34
|
+
className={cn(
|
|
35
|
+
"flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
36
|
+
tone === "outline" && "border hover:bg-muted",
|
|
37
|
+
// ⚠️ `foreground`/`background`, not literal black and white. The two swap with the theme on
|
|
38
|
+
// their own, so the label stays inverted in dark mode instead of turning into a black chip
|
|
39
|
+
// on a black ground.
|
|
40
|
+
tone === "inverted" && "bg-foreground text-background hover:bg-foreground/85",
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{label}
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BoardTask } from "@contract/board.ts";
|
|
2
|
+
|
|
3
|
+
/** One step of the path above an open card. The board is a step like any other. */
|
|
4
|
+
export interface Crumb {
|
|
5
|
+
/** The node id to open, or `null` for the board itself. */
|
|
6
|
+
taskId: string | null;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The path from the board down to the open card, board first.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **An unknown parent ENDS the walk rather than losing the card.** Under a filter a subtask can
|
|
14
|
+
* be in the answer while its parent is not (`board.ts` says so on `parentTaskId`). The path is then
|
|
15
|
+
* shorter than the tree, which is honest: it shows what this answer knows.
|
|
16
|
+
*/
|
|
17
|
+
export function crumbsOf(taskId: string, boardTitle: string, tasks: BoardTask[]): Crumb[] {
|
|
18
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
19
|
+
const trail: Crumb[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ **`seen` is the whole guard, and a second counter beside it would be decoration.** A loop in
|
|
24
|
+
* the parent chain ends the walk here because every id is visited once; a step limit could only
|
|
25
|
+
* fire where this already has, and a guard that cannot fire is one nobody can test.
|
|
26
|
+
*/
|
|
27
|
+
let current = byId.get(taskId);
|
|
28
|
+
while (current !== undefined) {
|
|
29
|
+
if (seen.has(current.id)) break;
|
|
30
|
+
seen.add(current.id);
|
|
31
|
+
trail.unshift({ taskId: current.id, title: current.title });
|
|
32
|
+
const parentId = current.parentTaskId;
|
|
33
|
+
current = parentId === null ? undefined : byId.get(parentId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [{ taskId: null, title: boardTitle }, ...trail];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The path as it is drawn: board, then an ellipsis standing for what was left out, then the last
|
|
41
|
+
* `keep` steps.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **The board and the open card are never hidden.** They are the two ends somebody navigates by:
|
|
44
|
+
* where they came from and where they are. Everything between them is what the ellipsis holds.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ **`hidden` is the LIST, not a count.** The three dots open it, so the view needs the entries
|
|
47
|
+
* themselves; handing over a number would force it to recompute what was dropped.
|
|
48
|
+
*/
|
|
49
|
+
export function foldCrumbs(crumbs: Crumb[], keep = 1): { shown: Crumb[]; hidden: Crumb[] } {
|
|
50
|
+
// Board + the kept tail + at least one in between is what makes folding worth anything.
|
|
51
|
+
if (crumbs.length <= keep + 2) return { shown: crumbs, hidden: [] };
|
|
52
|
+
return {
|
|
53
|
+
shown: [...crumbs.slice(0, 1), ...crumbs.slice(crumbs.length - keep)],
|
|
54
|
+
hidden: crumbs.slice(1, crumbs.length - keep),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ChevronLeft } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuItem,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
8
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
|
+
import { type Crumb, foldCrumbs } from "./board-crumbs.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The path above an open card: back to the board, then every level down to this one.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **The arrow belongs to the BOARD, the path to the cards.** Jack's decision 2026-08-21: one
|
|
15
|
+
* control that always means the same thing, and a path whose steps are each their own way back. An
|
|
16
|
+
* arrow that went one level up would mean something different on every card.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **This replaces the heading rather than standing beside it.** The last step IS the card's name;
|
|
19
|
+
* drawn next to a heading it would be the same sentence twice.
|
|
20
|
+
*/
|
|
21
|
+
export function BoardCrumbs({
|
|
22
|
+
crumbs,
|
|
23
|
+
onOpen,
|
|
24
|
+
}: {
|
|
25
|
+
crumbs: Crumb[];
|
|
26
|
+
onOpen(taskId: string | null): void;
|
|
27
|
+
}) {
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
const { shown, hidden } = foldCrumbs(crumbs);
|
|
30
|
+
const board = shown[0];
|
|
31
|
+
const rest = shown.slice(1);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<nav
|
|
35
|
+
aria-label={i18n.t("board.crumbs")}
|
|
36
|
+
className="flex min-w-0 items-center gap-1.5 text-lg font-semibold"
|
|
37
|
+
>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
|
|
41
|
+
onClick={() => onOpen(null)}
|
|
42
|
+
className="-ml-1 rounded-md p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
43
|
+
>
|
|
44
|
+
<ChevronLeft aria-hidden="true" className="size-5" />
|
|
45
|
+
</button>
|
|
46
|
+
|
|
47
|
+
<Step crumb={board} onOpen={onOpen} />
|
|
48
|
+
|
|
49
|
+
{hidden.length === 0 ? null : (
|
|
50
|
+
<>
|
|
51
|
+
<Separator />
|
|
52
|
+
<DropdownMenu>
|
|
53
|
+
{/* ⚠️ A menu, not a tooltip. On a touch screen there is no hover, and the levels behind
|
|
54
|
+
the dots are the only way to reach them — a hint that needs a pointer would put them
|
|
55
|
+
out of reach on the device where the path folds soonest. */}
|
|
56
|
+
<DropdownMenuTrigger
|
|
57
|
+
aria-label={i18n.t("board.crumbsFolded")}
|
|
58
|
+
className="rounded-md border px-1.5 leading-none text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
59
|
+
>
|
|
60
|
+
…
|
|
61
|
+
</DropdownMenuTrigger>
|
|
62
|
+
<DropdownMenuContent align="start">
|
|
63
|
+
{hidden.map((crumb) => (
|
|
64
|
+
<DropdownMenuItem key={crumb.taskId} onSelect={() => onOpen(crumb.taskId)}>
|
|
65
|
+
{crumb.title}
|
|
66
|
+
</DropdownMenuItem>
|
|
67
|
+
))}
|
|
68
|
+
</DropdownMenuContent>
|
|
69
|
+
</DropdownMenu>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{rest.map((crumb, step) => (
|
|
74
|
+
<span key={crumb.taskId} className="flex min-w-0 items-center gap-1.5">
|
|
75
|
+
<Separator />
|
|
76
|
+
{/* The last step is where the reader IS: named, not offered as somewhere to go. */}
|
|
77
|
+
{step === rest.length - 1 ? (
|
|
78
|
+
<span className="truncate font-medium">{crumb.title}</span>
|
|
79
|
+
) : (
|
|
80
|
+
<Step crumb={crumb} onOpen={onOpen} />
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
</nav>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const Separator = () => (
|
|
89
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
90
|
+
/
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
function Step({
|
|
95
|
+
crumb,
|
|
96
|
+
onOpen,
|
|
97
|
+
}: {
|
|
98
|
+
crumb: Crumb | undefined;
|
|
99
|
+
onOpen(taskId: string | null): void;
|
|
100
|
+
}) {
|
|
101
|
+
if (crumb === undefined) return null;
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => onOpen(crumb.taskId)}
|
|
106
|
+
className="truncate rounded-sm text-muted-foreground outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
107
|
+
>
|
|
108
|
+
{crumb.title}
|
|
109
|
+
</button>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BoardColumn,
|
|
3
|
+
BoardTask,
|
|
4
|
+
BoardTaskCreateInput,
|
|
5
|
+
BoardTaskFilter,
|
|
6
|
+
BoardTaskUpdateInput,
|
|
7
|
+
BoardView,
|
|
8
|
+
} from "@contract/board.ts";
|
|
9
|
+
import { ARCHIVE_COLUMN_ID } from "@contract/board.ts";
|
|
10
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { threaded } from "@/board/board-stripes/board-stripes.ts";
|
|
13
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
14
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
15
|
+
import type { BoardHandle, ColumnWithCards } from "./board-data.types.ts";
|
|
16
|
+
|
|
17
|
+
export const boardKey = (boardId: string) => ["board", boardId] as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether this filter narrows anything at all.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **`Object.keys(...).length` is the wrong question**, because clearing a control writes the key
|
|
23
|
+
* back with `undefined` rather than removing it — `{ status: undefined }` has one key and filters
|
|
24
|
+
* nothing. Read that as "filtering" and the table says "nothing matches this filter" about a board
|
|
25
|
+
* nobody is filtering, and a write pays a refetch it does not owe.
|
|
26
|
+
*/
|
|
27
|
+
export function isFiltering(filter: Partial<BoardTaskFilter>): boolean {
|
|
28
|
+
return Object.values(filter).some((value) => value !== undefined);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where a card lands when it is dropped between two neighbours.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ **The midpoint, and that is the whole reason `position` is a float.** With integers a drop
|
|
35
|
+
* would renumber every row below it, two people dropping at once would fight over rows neither
|
|
36
|
+
* touched, and a retry would move the card again. Here one card is written and nothing else is.
|
|
37
|
+
*
|
|
38
|
+
* The two edges are the cases a midpoint cannot express: dropped at the top there is nothing above,
|
|
39
|
+
* dropped at the bottom nothing below. `1` away from the neighbour keeps room for the next drop on
|
|
40
|
+
* that side without ever needing to renumber.
|
|
41
|
+
*/
|
|
42
|
+
export function positionBetween(above: number | undefined, below: number | undefined): number {
|
|
43
|
+
if (above === undefined && below === undefined) return 1;
|
|
44
|
+
if (above === undefined) return (below ?? 1) - 1;
|
|
45
|
+
if (below === undefined) return above + 1;
|
|
46
|
+
return (above + below) / 2;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The board as the views draw it: columns in their configured order, each carrying its own cards
|
|
51
|
+
* sorted by position.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ **A card whose status names no column is kept, in a column of its own at the end.** Dropping
|
|
54
|
+
* it would be the quiet failure: somebody renames a column, every card in it stops being drawn, and
|
|
55
|
+
* the screen looks like the cards were deleted. A visible stray column is ugly and true.
|
|
56
|
+
*/
|
|
57
|
+
export function columnsWithCards(view: BoardView): ColumnWithCards[] {
|
|
58
|
+
const byStatus = new Map<string, BoardTask[]>();
|
|
59
|
+
for (const task of view.tasks) {
|
|
60
|
+
const cards = byStatus.get(task.status);
|
|
61
|
+
if (cards === undefined) byStatus.set(task.status, [task]);
|
|
62
|
+
else cards.push(task);
|
|
63
|
+
}
|
|
64
|
+
// ⚠️ Threaded, not merely sorted: a subtask that sits in the SAME column as its parent follows
|
|
65
|
+
// it directly, and its own children follow that. One that sits elsewhere takes its ordinary place
|
|
66
|
+
// by position — there is no thread here for it to join (`board-stripes.ts`).
|
|
67
|
+
const sorted = (cards: BoardTask[]) => threaded(cards);
|
|
68
|
+
const known: ColumnWithCards[] = view.columns.map((column) => ({
|
|
69
|
+
column,
|
|
70
|
+
cards: sorted(byStatus.get(column.id) ?? []),
|
|
71
|
+
}));
|
|
72
|
+
const orphaned = [...byStatus.keys()].filter(
|
|
73
|
+
(status) => !view.columns.some((column) => column.id === status),
|
|
74
|
+
);
|
|
75
|
+
const strays: ColumnWithCards[] = orphaned.map((status) => ({
|
|
76
|
+
column: { id: status, title: status, terminal: false } satisfies BoardColumn,
|
|
77
|
+
cards: sorted(byStatus.get(status) ?? []),
|
|
78
|
+
unknown: true,
|
|
79
|
+
}));
|
|
80
|
+
return [...known, ...strays];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Everything a board view needs, from one query and three mutations.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ **Every write answers with the whole board, and the answer is written straight into the
|
|
87
|
+
* cache.** The alternative — patch the cached board from the row that changed — is a second place
|
|
88
|
+
* that decides what a column contains, and the two drift the first time the server does something
|
|
89
|
+
* the client did not predict. A move, for instance, also gives the card a new position when none
|
|
90
|
+
* was sent.
|
|
91
|
+
*/
|
|
92
|
+
export function useBoard(boardId: string, filter: Partial<BoardTaskFilter> = {}): BoardHandle {
|
|
93
|
+
const { data } = useIntelRouterContext();
|
|
94
|
+
const i18n = useI18n();
|
|
95
|
+
const client = useQueryClient();
|
|
96
|
+
const key = boardKey(boardId);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ⚠️ **The filter belongs in the key, and `includeArchived` is only its most obvious member.**
|
|
100
|
+
* Without it every filtered view of a board shares one cache entry: switching a filter shows the
|
|
101
|
+
* previous selection's cards until the fetch returns, which reads as cards appearing and
|
|
102
|
+
* vanishing on their own.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ **The filter travels; it is not applied here.** A view that fetched everything and hid rows
|
|
105
|
+
* in the browser looks identical and is a different thing — `board-table.unit.tsx` therefore
|
|
106
|
+
* asserts the ARGUMENT of the second request, not just that a request happened.
|
|
107
|
+
*/
|
|
108
|
+
const scoped = [...key, filter] as const;
|
|
109
|
+
const unfiltered = [...key, {}] as const;
|
|
110
|
+
const query = useQuery({
|
|
111
|
+
queryKey: scoped,
|
|
112
|
+
// `includeArchived` is spelled out because the provider takes the contract's OUTPUT type,
|
|
113
|
+
// where the default has already been applied and the field is therefore required.
|
|
114
|
+
queryFn: async () => await data.getBoard({ includeArchived: false, boardId, ...filter }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where a write's answer goes.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ **A write always answers with the board UNFILTERED, whatever this handle is filtered by.**
|
|
121
|
+
* All three write paths ask for the view with a fixed `{ boardId, includeArchived: false }`
|
|
122
|
+
* (`packages/intel/src/boards/boards.ts`), and none of the three inputs even carries a filter field.
|
|
123
|
+
* So the answer belongs in the unfiltered entry — and settling it into the filtered one would put
|
|
124
|
+
* every card into a view that asked for some of them, with no error, because the write succeeded.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ **The filtered entry is therefore invalidated rather than patched.** This answer cannot say
|
|
127
|
+
* what the filter now matches: a card that was moved out of it has to disappear, one moved into it
|
|
128
|
+
* has to appear, and only the server knows which. One extra request per write under a filter, and
|
|
129
|
+
* an unfiltered board — the ordinary case — still pays none.
|
|
130
|
+
*/
|
|
131
|
+
const settle = (view: BoardView) => {
|
|
132
|
+
client.setQueryData(unfiltered, view);
|
|
133
|
+
if (isFiltering(filter)) void client.invalidateQueries({ queryKey: scoped });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const createTask = useMutation({
|
|
137
|
+
mutationFn: async (input: Omit<BoardTaskCreateInput, "boardId">) =>
|
|
138
|
+
await data.createBoardTask({ ...input, boardId }),
|
|
139
|
+
onSuccess: settle,
|
|
140
|
+
});
|
|
141
|
+
const updateTask = useMutation({
|
|
142
|
+
mutationFn: async (input: BoardTaskUpdateInput) => await data.updateBoardTask(boardId, input),
|
|
143
|
+
onSuccess: settle,
|
|
144
|
+
});
|
|
145
|
+
const setColumns = useMutation({
|
|
146
|
+
mutationFn: async (input: {
|
|
147
|
+
columns: BoardColumn[];
|
|
148
|
+
archiveVisible?: boolean;
|
|
149
|
+
idempotencyKey: string;
|
|
150
|
+
}) => await data.updateBoard({ boardId, ...input }),
|
|
151
|
+
onSuccess: settle,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ⚠️ **The archive column is said in the reader's own words, here and nowhere else.** It is
|
|
156
|
+
* derived and identical on every board, so unlike a configured column — shared data, written
|
|
157
|
+
* once in the creator's language (#675) — it is presentation. The server sends English for
|
|
158
|
+
* everything without a catalog; this is the one place a browser has one.
|
|
159
|
+
*/
|
|
160
|
+
const columns = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
query.data === undefined
|
|
163
|
+
? []
|
|
164
|
+
: columnsWithCards(query.data).map((entry) =>
|
|
165
|
+
entry.column.id === ARCHIVE_COLUMN_ID
|
|
166
|
+
? { ...entry, column: { ...entry.column, title: i18n.t("board.column.archive") } }
|
|
167
|
+
: entry,
|
|
168
|
+
),
|
|
169
|
+
[query.data, i18n],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
boardId,
|
|
174
|
+
title: query.data?.title ?? "",
|
|
175
|
+
// Until the board has answered there is nothing to hide, and `true` is what a board without a
|
|
176
|
+
// stored setting answers with anyway.
|
|
177
|
+
archiveVisible: query.data?.archiveVisible ?? true,
|
|
178
|
+
columns,
|
|
179
|
+
isPending: query.isPending,
|
|
180
|
+
isError: query.isError,
|
|
181
|
+
// ⚠️ One flag for all three writes. A screen that disabled only the button it just pressed
|
|
182
|
+
// would let a second drag start while the first is in flight, and the second answer would
|
|
183
|
+
// overwrite the first — with no error, because both succeeded.
|
|
184
|
+
isWriting: createTask.isPending || updateTask.isPending || setColumns.isPending,
|
|
185
|
+
createTask: async (input) => {
|
|
186
|
+
await createTask.mutateAsync(input);
|
|
187
|
+
},
|
|
188
|
+
updateTask: async (input) => {
|
|
189
|
+
await updateTask.mutateAsync(input);
|
|
190
|
+
},
|
|
191
|
+
setColumns: async (input) => {
|
|
192
|
+
await setColumns.mutateAsync(input);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|