@dxos/react-ui-query 0.12.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/LICENSE +105 -0
- package/README.md +5 -0
- package/dist/lib/index.mjs +637 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.d.ts +37 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts +42 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/autocomplete.d.ts +8 -0
- package/dist/types/src/components/QueryEditor/autocomplete.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts +2 -0
- package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/index.d.ts +3 -0
- package/dist/types/src/components/QueryEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/query-extension.d.ts +28 -0
- package/dist/types/src/components/QueryEditor/query-extension.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/query-extension.test.d.ts +2 -0
- package/dist/types/src/components/QueryEditor/query-extension.test.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/Picker.d.ts +15 -0
- package/dist/types/src/components/QueryForm/Picker.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/QueryForm.d.ts +15 -0
- package/dist/types/src/components/QueryForm/QueryForm.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts +26 -0
- package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/index.d.ts +2 -0
- package/dist/types/src/components/QueryForm/index.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/query.d.ts +6 -0
- package/dist/types/src/components/QueryForm/query.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useQueryBuilder.d.ts +6 -0
- package/dist/types/src/hooks/useQueryBuilder.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +13 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +78 -0
- package/src/components/QueryEditor/QueryEditor.stories.tsx +165 -0
- package/src/components/QueryEditor/QueryEditor.tsx +136 -0
- package/src/components/QueryEditor/autocomplete.test.ts +46 -0
- package/src/components/QueryEditor/autocomplete.ts +76 -0
- package/src/components/QueryEditor/index.ts +7 -0
- package/src/components/QueryEditor/query-extension.test.ts +176 -0
- package/src/components/QueryEditor/query-extension.ts +532 -0
- package/src/components/QueryForm/Picker.tsx +47 -0
- package/src/components/QueryForm/QueryForm.stories.tsx +72 -0
- package/src/components/QueryForm/QueryForm.tsx +77 -0
- package/src/components/QueryForm/index.ts +5 -0
- package/src/components/QueryForm/query.ts +91 -0
- package/src/components/index.ts +6 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useQueryBuilder.ts +25 -0
- package/src/index.ts +6 -0
- package/src/translations.ts +22 -0
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/react-ui-query",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Editors for ECHO queries: a query-language input and a type/tag form.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
12
|
+
"author": "DXOS.org",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"type": "module",
|
|
15
|
+
"imports": {
|
|
16
|
+
"#translations": {
|
|
17
|
+
"source": "./src/translations.ts",
|
|
18
|
+
"types": "./dist/types/src/translations.d.ts",
|
|
19
|
+
"default": "./dist/lib/translations.mjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"source": "./src/index.ts",
|
|
25
|
+
"types": "./dist/types/src/index.d.ts",
|
|
26
|
+
"import": "./dist/lib/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./translations": {
|
|
29
|
+
"source": "./src/translations.ts",
|
|
30
|
+
"types": "./dist/types/src/translations.d.ts",
|
|
31
|
+
"import": "./dist/lib/translations.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"types": "dist/types/src/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@codemirror/language": "^6.12.4",
|
|
41
|
+
"@codemirror/state": "^6.7.4",
|
|
42
|
+
"@codemirror/view": "^6.43.11",
|
|
43
|
+
"@lezer/common": "^1.5.2",
|
|
44
|
+
"@lezer/highlight": "^1.2.3",
|
|
45
|
+
"json5": "^2.2.3",
|
|
46
|
+
"@dxos/echo": "0.12.0",
|
|
47
|
+
"@dxos/keys": "0.12.0",
|
|
48
|
+
"@dxos/echo-query": "0.12.0",
|
|
49
|
+
"@dxos/ui-editor": "0.12.0",
|
|
50
|
+
"@dxos/ui": "0.12.0",
|
|
51
|
+
"@dxos/react-ui-editor": "0.12.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@storybook/react-vite": "^10.6.0",
|
|
55
|
+
"@types/react": "~19.2.17",
|
|
56
|
+
"@types/react-dom": "~19.2.3",
|
|
57
|
+
"effect": "4.0.0",
|
|
58
|
+
"jsdom": "^29.1.1",
|
|
59
|
+
"react": "~19.2.7",
|
|
60
|
+
"react-dom": "~19.2.7",
|
|
61
|
+
"vite": "^8.2.2",
|
|
62
|
+
"@dxos/react-client": "0.12.0",
|
|
63
|
+
"@dxos/react-ui-syntax-highlighter": "0.12.0",
|
|
64
|
+
"@dxos/react-ui": "0.12.0",
|
|
65
|
+
"@dxos/ui-theme": "0.12.0",
|
|
66
|
+
"@dxos/types": "0.12.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"effect": "4.0.0",
|
|
70
|
+
"react": "~19.2.7",
|
|
71
|
+
"react-dom": "~19.2.7",
|
|
72
|
+
"@dxos/react-ui": "^0.12.0",
|
|
73
|
+
"@dxos/ui-theme": "^0.12.0"
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorView } from '@codemirror/view';
|
|
6
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
7
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
8
|
+
import { expect, userEvent, waitFor } from 'storybook/test';
|
|
9
|
+
|
|
10
|
+
import { type Filter, Tag } from '@dxos/echo';
|
|
11
|
+
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
12
|
+
import { Toolbar } from '@dxos/react-ui';
|
|
13
|
+
import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
|
|
14
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
15
|
+
import { Employer, Organization, Person, Pipeline } from '@dxos/types';
|
|
16
|
+
|
|
17
|
+
import { translations } from '#translations';
|
|
18
|
+
|
|
19
|
+
import { QueryEditor, type QueryEditorProps } from './QueryEditor.tsx';
|
|
20
|
+
|
|
21
|
+
// Create tags at render time to avoid Storybook serialization issues with ECHO objects.
|
|
22
|
+
const createTags = (): Tag.Map => ({
|
|
23
|
+
tag_1: Tag.make({ label: 'Important' }),
|
|
24
|
+
tag_2: Tag.make({ label: 'Investor' }),
|
|
25
|
+
tag_3: Tag.make({ label: 'New' }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const DefaultStory = (args: QueryEditorProps) => {
|
|
29
|
+
const { space } = useClientStory();
|
|
30
|
+
const [filter, setFilter] = useState<Filter.Any>();
|
|
31
|
+
// Create tags and builder at render time to avoid Storybook serialization issues.
|
|
32
|
+
const tags = useMemo(() => args.tags ?? createTags(), [args.tags]);
|
|
33
|
+
|
|
34
|
+
// The editor parses the DSL itself; a story that rebuilt the filter here would be exercising its
|
|
35
|
+
// own `QueryBuilder` rather than the component's.
|
|
36
|
+
const handleFilterChange = useCallback<NonNullable<QueryEditorProps['onFilterChange']>>(
|
|
37
|
+
({ filter }) => setFilter(filter),
|
|
38
|
+
[],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className='flex flex-col gap-2'>
|
|
43
|
+
<Toolbar.Root>
|
|
44
|
+
<QueryEditor {...args} db={space?.db} tags={tags} onFilterChange={handleFilterChange} />
|
|
45
|
+
</Toolbar.Root>
|
|
46
|
+
|
|
47
|
+
<JsonHighlighter data={filter} classNames='text-xs' />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const meta = {
|
|
53
|
+
title: 'ui/react-ui-query/QueryEditor',
|
|
54
|
+
component: QueryEditor,
|
|
55
|
+
render: (args: QueryEditorProps) => <DefaultStory {...args} />,
|
|
56
|
+
decorators: [
|
|
57
|
+
withTheme(),
|
|
58
|
+
withLayout({ layout: 'column' }),
|
|
59
|
+
withClientProvider({
|
|
60
|
+
types: [Organization.Organization, Person.Person, Pipeline.Pipeline, Employer.Employer],
|
|
61
|
+
createIdentity: true,
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
parameters: {
|
|
65
|
+
translations,
|
|
66
|
+
},
|
|
67
|
+
} satisfies Meta<typeof QueryEditor>;
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
|
|
71
|
+
type Story = StoryObj<typeof meta>;
|
|
72
|
+
|
|
73
|
+
export const Default: Story = {};
|
|
74
|
+
|
|
75
|
+
export const Complex: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
autoFocus: true,
|
|
78
|
+
value: '#important OR type:org.dxos.type.person AND { title: "DXOS", value: true }',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Relation: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
autoFocus: true,
|
|
85
|
+
value: '(type:org.dxos.type.person -> type:org.dxos.type.organization)',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const Tags: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
autoFocus: true,
|
|
92
|
+
value: 'type:org.dxos.type.person #investor #new',
|
|
93
|
+
},
|
|
94
|
+
play: async ({ canvasElement }) => {
|
|
95
|
+
const content = await waitFor(() => {
|
|
96
|
+
const element = canvasElement.querySelector<HTMLElement>('.cm-content');
|
|
97
|
+
if (!element) {
|
|
98
|
+
throw new Error('Query editor content not found.');
|
|
99
|
+
}
|
|
100
|
+
return element;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The document, not `textContent`: a type filter is drawn as a widget, so the rendered text is
|
|
104
|
+
// the chip's label rather than what the document holds.
|
|
105
|
+
const view = EditorView.findFromDOM(content)!;
|
|
106
|
+
|
|
107
|
+
// `selectionEnd` puts the caret at the end of the document on the first render.
|
|
108
|
+
await waitFor(() => expect(view.state.selection.main.head).toEqual(view.state.doc.length));
|
|
109
|
+
|
|
110
|
+
// Which is what makes the first keystroke append rather than land in front of the query.
|
|
111
|
+
await userEvent.keyboard('X');
|
|
112
|
+
await waitFor(() => expect(view.state.doc.toString()).toEqual('type:org.dxos.type.person #investor #newX '));
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A tag behaves as a single object: one Backspace or Delete removes the whole chip, and text typed
|
|
118
|
+
* against either edge is separated from it by a space, with a trailing space always left to type
|
|
119
|
+
* into. Driven through real keystrokes, since atomicity is enforced by CodeMirror's own cursor and
|
|
120
|
+
* delete commands rather than by the decorations the unit tests inspect.
|
|
121
|
+
*/
|
|
122
|
+
export const Atomic: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
autoFocus: true,
|
|
125
|
+
value: '#test',
|
|
126
|
+
},
|
|
127
|
+
play: async ({ canvasElement }) => {
|
|
128
|
+
const content = await waitFor(() => {
|
|
129
|
+
const element = canvasElement.querySelector<HTMLElement>('.cm-content');
|
|
130
|
+
if (!element) {
|
|
131
|
+
throw new Error('Query editor content not found.');
|
|
132
|
+
}
|
|
133
|
+
return element;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// The placeholder renders inside the content element, so an empty document is not empty text.
|
|
137
|
+
const doc = () => (content.querySelector('.cm-placeholder') ? '' : content.textContent);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => expect(doc()).toEqual('#test'));
|
|
140
|
+
|
|
141
|
+
// No click and no `{End}`: `selectionEnd` is what puts the caret at the end of the document on
|
|
142
|
+
// the first render, and the caret at the tag's own edge is still in the tag — so this single
|
|
143
|
+
// Backspace both proves that and takes the whole chip.
|
|
144
|
+
// `keyboard`, not `type`: the latter clicks the element first, which would move the caret.
|
|
145
|
+
await userEvent.keyboard('{Backspace}');
|
|
146
|
+
await waitFor(() => expect(doc()).toEqual(''));
|
|
147
|
+
|
|
148
|
+
// Atomic does not mean uneditable: each character lands outside the replaced range and grows it,
|
|
149
|
+
// so the tag is still typed a character at a time. The chip renders the label the whole way.
|
|
150
|
+
await userEvent.keyboard('#te');
|
|
151
|
+
await waitFor(() => expect(doc()).toEqual('#te '));
|
|
152
|
+
await userEvent.keyboard('st');
|
|
153
|
+
await waitFor(() => expect(doc()).toEqual('#test '));
|
|
154
|
+
|
|
155
|
+
// Typed text is never glued to a chip, in either direction.
|
|
156
|
+
await userEvent.keyboard('{Home}X');
|
|
157
|
+
await waitFor(() => expect(doc()).toEqual('X #test '));
|
|
158
|
+
await userEvent.keyboard('{End}Y');
|
|
159
|
+
await waitFor(() => expect(doc()).toEqual('X #test Y '));
|
|
160
|
+
|
|
161
|
+
// Two deletes for `X `, then a single one for the whole five-character tag.
|
|
162
|
+
await userEvent.keyboard('{Home}{Delete}{Delete}{Delete}');
|
|
163
|
+
await waitFor(() => expect(doc()).toEqual(' Y '));
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, Prec } from '@codemirror/state';
|
|
6
|
+
import React, { forwardRef, useCallback, useMemo, useRef } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type BuildResult, QueryBuilder } from '@dxos/echo-query';
|
|
9
|
+
import { type ThemedClassName, useThemeMode, useTranslation } from '@dxos/react-ui';
|
|
10
|
+
import {
|
|
11
|
+
Editor,
|
|
12
|
+
type EditorController,
|
|
13
|
+
type EditorMenuProviderProps,
|
|
14
|
+
type EditorViewProps,
|
|
15
|
+
type UseEditorMenuProps,
|
|
16
|
+
createMenuGroup,
|
|
17
|
+
} from '@dxos/react-ui-editor';
|
|
18
|
+
import { createBasicExtensions, createThemeExtensions, keymap } from '@dxos/ui-editor';
|
|
19
|
+
|
|
20
|
+
import { translationKey } from '#translations';
|
|
21
|
+
|
|
22
|
+
import { type CompletionOptions, completions } from './autocomplete.ts';
|
|
23
|
+
import { query } from './query-extension.ts';
|
|
24
|
+
|
|
25
|
+
export type QueryEditorProps = ThemedClassName<
|
|
26
|
+
{
|
|
27
|
+
value?: string;
|
|
28
|
+
readonly?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The parsed query, rebuilt on every edit. `filter` is undefined while the text does not parse,
|
|
31
|
+
* which is what a caller gates a "save this view" affordance on.
|
|
32
|
+
*
|
|
33
|
+
* Supplying this is what opts the editor into building a filter at all — the DSL is otherwise
|
|
34
|
+
* parsed only for decoration, and a caller that wants the raw text pays nothing for a parse it
|
|
35
|
+
* will not read.
|
|
36
|
+
*/
|
|
37
|
+
onFilterChange?: (result: BuildResult) => void;
|
|
38
|
+
} & (CompletionOptions & Omit<EditorViewProps, 'initialValue'> & Pick<EditorMenuProviderProps, 'numItems'>)
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Query editor with decorations and autocomplete.
|
|
43
|
+
*/
|
|
44
|
+
export const QueryEditor = forwardRef<EditorController, QueryEditorProps>(
|
|
45
|
+
({ db, tags, value, readonly, numItems = 8, onChange, onFilterChange, ...props }, forwardedRef) => {
|
|
46
|
+
const { t } = useTranslation(translationKey);
|
|
47
|
+
|
|
48
|
+
const getOptions = useMemo(() => completions({ db, tags }), [db, tags]);
|
|
49
|
+
const getMenu = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
|
|
50
|
+
async (context) => [createMenuGroup({ items: getOptions(context) })],
|
|
51
|
+
[getOptions],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// The editor owns the parse that used to be repeated by every consumer: the DSL was already being
|
|
55
|
+
// parsed here for decorations, so a caller building its own `QueryBuilder` parsed each keystroke
|
|
56
|
+
// twice.
|
|
57
|
+
const builder = useMemo(() => (onFilterChange ? new QueryBuilder(tags) : undefined), [onFilterChange, tags]);
|
|
58
|
+
const handleChange = useCallback(
|
|
59
|
+
(doc: string) => {
|
|
60
|
+
onChange?.(doc);
|
|
61
|
+
if (builder) {
|
|
62
|
+
onFilterChange?.(builder.build(doc));
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[onChange, onFilterChange, builder],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Keyed on the tag map's CONTENTS, not its identity: callers rebuild the map each render, and
|
|
69
|
+
// `useTextEditor` destroys and recreates the view whenever `extensions` changes — so depending on
|
|
70
|
+
// the object blurred the editor on every keystroke.
|
|
71
|
+
const tagsKey = useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
Object.values(tags ?? {})
|
|
74
|
+
.map((tag) => `${tag.id}:${tag.label}:${tag.hue ?? ''}`)
|
|
75
|
+
.join(),
|
|
76
|
+
[tags],
|
|
77
|
+
);
|
|
78
|
+
const tagsRef = useRef(tags);
|
|
79
|
+
tagsRef.current = tags;
|
|
80
|
+
|
|
81
|
+
const themeMode = useThemeMode();
|
|
82
|
+
const extensions = useMemo<Extension[]>(
|
|
83
|
+
() => [
|
|
84
|
+
createBasicExtensions({
|
|
85
|
+
readOnly: readonly,
|
|
86
|
+
lineWrapping: false,
|
|
87
|
+
placeholder: t('query-editor.placeholder'),
|
|
88
|
+
// A single-line query gains nothing from bracket matching, and CodeMirror's default paints
|
|
89
|
+
// `.cm-matchingBracket` with a background while focused — a coloured wash over the braces of
|
|
90
|
+
// an object literal the moment the caret reaches it.
|
|
91
|
+
bracketMatching: false,
|
|
92
|
+
}),
|
|
93
|
+
createThemeExtensions({ themeMode, slots: { scroller: { className: 'scrollbar-none' } } }),
|
|
94
|
+
query({ tags: tagsRef.current }),
|
|
95
|
+
Prec.highest(
|
|
96
|
+
keymap.of([
|
|
97
|
+
{
|
|
98
|
+
key: 'Enter',
|
|
99
|
+
run: () => {
|
|
100
|
+
// Prevent newline.
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
]),
|
|
105
|
+
),
|
|
106
|
+
],
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
[readonly, themeMode, tagsKey],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Editor.Root
|
|
113
|
+
ref={forwardedRef}
|
|
114
|
+
extensions={extensions}
|
|
115
|
+
numItems={numItems}
|
|
116
|
+
// `#` opens the tag list. `:` cannot be a trigger — `popoverKeymap` only fires one at a line
|
|
117
|
+
// start or after a space, and a typename's colon follows `type`; `activateOnTyping` covers it
|
|
118
|
+
// instead, since `:` is a completion delimiter so the word under the caret is the typename.
|
|
119
|
+
// The explicit key stays, for re-opening a dismissed menu.
|
|
120
|
+
trigger={['#']}
|
|
121
|
+
// NOT `activateOnTyping`: that opens the menu on every word character, and the popover takes
|
|
122
|
+
// focus each keystroke while returning nothing for most positions. `:` is the only delimiter
|
|
123
|
+
// with a list behind it, so the typename menu opens there and nowhere else.
|
|
124
|
+
activateOnDelimiters={[':']}
|
|
125
|
+
triggerKey='Ctrl-Space'
|
|
126
|
+
getMenu={getMenu}
|
|
127
|
+
>
|
|
128
|
+
{/* `initialValue`, never the controlled `value`: the sync effect behind `value` compares the
|
|
129
|
+
prop against the document a frame later, and a caller whose state trails fast typing then
|
|
130
|
+
rewrites the document back to the older text, dropping characters. A caller rewriting the
|
|
131
|
+
query itself does it through `EditorController.setText`. */}
|
|
132
|
+
<Editor.View {...props} initialValue={value} onChange={handleChange} selectionEnd />
|
|
133
|
+
</Editor.Root>
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorState } from '@codemirror/state';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { Tag } from '@dxos/echo';
|
|
9
|
+
|
|
10
|
+
import { completions } from './autocomplete.ts';
|
|
11
|
+
|
|
12
|
+
const tags: Tag.Map = {
|
|
13
|
+
tag_1: Tag.make({ label: 'important' }),
|
|
14
|
+
tag_2: Tag.make({ label: 'investor' }),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Selecting a completion replaces the WHOLE trigger range — the `#` included — and inserts the item
|
|
19
|
+
* verbatim, so an unprefixed label would drop the `#` and leave text that no longer parses as a tag.
|
|
20
|
+
*/
|
|
21
|
+
describe('tag completions', () => {
|
|
22
|
+
test('carry the `#`, so the inserted text is still a tag', () => {
|
|
23
|
+
expect(optionsAt('#')).toEqual(['#important', '#investor']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('are offered while the label is being typed', () => {
|
|
27
|
+
expect(optionsAt('#imp')).toEqual(['#important', '#investor']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('are offered for a tag later in the query', () => {
|
|
31
|
+
expect(optionsAt('type:org.dxos.Person AND #inv')).toEqual(['#important', '#investor']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('are not offered away from a tag', () => {
|
|
35
|
+
expect(optionsAt('type:')).not.toContain('#important');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Only once the string is closed: until then the parser recovers by reading the `"` as an error and
|
|
39
|
+
// `#imp` as a real tag, so the tree has no string to resolve into.
|
|
40
|
+
test('are not offered inside a closed string, where `#` is content', () => {
|
|
41
|
+
expect(optionsAt('{ title: "#imp" }', 14)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const optionsAt = (doc: string, pos = doc.length) =>
|
|
46
|
+
completions({ tags })({ state: EditorState.create({ doc }), pos, text: doc });
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Database, type Tag, Type } from '@dxos/echo';
|
|
6
|
+
import { QueryDSL, formatTag } from '@dxos/echo-query';
|
|
7
|
+
import { type GetMenuContext } from '@dxos/react-ui-editor';
|
|
8
|
+
|
|
9
|
+
export type CompletionOptions = {
|
|
10
|
+
db?: Database.Database;
|
|
11
|
+
tags?: Tag.Map;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tag labels as they must appear in the document. Selecting a completion REPLACES the whole trigger
|
|
16
|
+
* range, `#` included, and inserts the item verbatim — so an unprefixed label would silently drop the
|
|
17
|
+
* `#` and leave text that no longer parses as a tag.
|
|
18
|
+
*/
|
|
19
|
+
const tagCompletions = (tags: Tag.Map): string[] => Object.values(tags).map((tag) => formatTag(tag.label));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the caret sits in a tag the user is still typing: a `#` at the start of the current word,
|
|
23
|
+
* with only label characters between it and `pos`.
|
|
24
|
+
*/
|
|
25
|
+
const isTypingTag = (text: string, pos: number): boolean => {
|
|
26
|
+
let index = pos;
|
|
27
|
+
while (index > 0 && /[a-zA-Z0-9_-]/.test(text[index - 1])) {
|
|
28
|
+
index--;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return index > 0 && text[index - 1] === '#';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const completions = ({ db, tags }: CompletionOptions) => {
|
|
35
|
+
const parser = QueryDSL.Parser.configure({ strict: false });
|
|
36
|
+
return ({ state, pos }: GetMenuContext): string[] => {
|
|
37
|
+
const text = state.sliceDoc();
|
|
38
|
+
const tree = parser.parse(text);
|
|
39
|
+
const { node } = tree.cursorAt(pos, -1);
|
|
40
|
+
|
|
41
|
+
// A `#` with no label yet is an error node, not a `TagFilter` — the grammar needs a label
|
|
42
|
+
// character before it accepts one — so the tree cannot answer for the keystroke that opens a tag.
|
|
43
|
+
// Inside a string it is content, so the tree is still what settles that.
|
|
44
|
+
if (tags && isTypingTag(text, pos) && node.type.id !== QueryDSL.Node.String) {
|
|
45
|
+
return tagCompletions(tags);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
switch (node.parent?.type.id) {
|
|
49
|
+
case QueryDSL.Node.TypeFilter: {
|
|
50
|
+
let range: { from: number; to: number } | undefined;
|
|
51
|
+
if (node?.type.id === QueryDSL.Node.Identifier) {
|
|
52
|
+
range = { from: node.from, to: node.to };
|
|
53
|
+
} else if (node?.type.name === ':') {
|
|
54
|
+
range = { from: node.from + 1, to: node.to };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (range) {
|
|
58
|
+
const schema = db ? [...db.graph.registry.list().filter(Type.isType)] : [];
|
|
59
|
+
return schema.map((schema) => Type.getTypename(schema));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case QueryDSL.Node.TagFilter: {
|
|
66
|
+
if (tags) {
|
|
67
|
+
return tagCompletions(tags);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return [];
|
|
75
|
+
};
|
|
76
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorSelection, EditorState } from '@codemirror/state';
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
|
+
import { describe, expect, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Tag } from '@dxos/echo';
|
|
10
|
+
import { getHashHue } from '@dxos/ui-theme';
|
|
11
|
+
|
|
12
|
+
import { buildQueryDecorations, query } from './query-extension.ts';
|
|
13
|
+
|
|
14
|
+
const tag = Tag.make({ label: 'important' });
|
|
15
|
+
const tags: Tag.Map = { [tag.id]: tag };
|
|
16
|
+
const hue = tag.hue ?? getHashHue(tag.id);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A tag is one object: a widget REPLACES its text, atomically, wherever the caret is — including at
|
|
20
|
+
* the tag's own edges, so Backspace against it removes the whole chip rather than a character of the
|
|
21
|
+
* label. It is still typed a character at a time, since an insertion at the range's boundary lands
|
|
22
|
+
* outside it and grows the node.
|
|
23
|
+
*/
|
|
24
|
+
describe('tag rendering', () => {
|
|
25
|
+
test('is an atomic chip when the caret is elsewhere', async () => {
|
|
26
|
+
const view = await viewOf('#important AND #other', 0);
|
|
27
|
+
expect(decorationsOf(view)).toMatchObject([
|
|
28
|
+
{ from: 0, to: 10, atomic: true },
|
|
29
|
+
{ from: 11, to: 14, atomic: true },
|
|
30
|
+
{ from: 15, to: 21, atomic: true },
|
|
31
|
+
]);
|
|
32
|
+
view.destroy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('stays an atomic chip with the caret inside it', async () => {
|
|
36
|
+
const view = await viewOf('#important', 5);
|
|
37
|
+
expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 10, atomic: true }]);
|
|
38
|
+
view.destroy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('stays an atomic chip with the caret at its trailing edge', async () => {
|
|
42
|
+
// Regression: the caret lands here both while the tag is being typed and after `End`, and an
|
|
43
|
+
// exception for it left Backspace deleting a character of the label instead of the whole tag.
|
|
44
|
+
const view = await viewOf('#important', 10);
|
|
45
|
+
expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 10, atomic: true }]);
|
|
46
|
+
view.destroy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a bare `#` is marked from the keystroke that opens it', async () => {
|
|
50
|
+
// The grammar needs a label character before it accepts a `Tag`, so this comes from a document
|
|
51
|
+
// scan rather than the tree — the affordance cannot wait for the parse to succeed.
|
|
52
|
+
const view = await viewOf('#', 1);
|
|
53
|
+
expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 1, atomic: false }]);
|
|
54
|
+
view.destroy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('every tag is a chip, wherever the caret is', async () => {
|
|
58
|
+
const view = await viewOf('#alpha #beta', 12);
|
|
59
|
+
const ranges = decorationsOf(view);
|
|
60
|
+
expect(ranges.filter((range) => range.atomic).map(({ from, to }) => [from, to])).toEqual([
|
|
61
|
+
[0, 6],
|
|
62
|
+
[7, 12],
|
|
63
|
+
]);
|
|
64
|
+
view.destroy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the label is rendered once', async () => {
|
|
68
|
+
const view = await viewOf('#important', 10);
|
|
69
|
+
expect(view.contentDOM.textContent).toBe('#important');
|
|
70
|
+
view.destroy();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decorations are fed to a `RangeSetBuilder`, which throws on an out-of-order range. The bare-`#`
|
|
76
|
+
* ranges come from a document scan rather than the syntax tree, so they interleave with the tree's
|
|
77
|
+
* own and have to be sorted before being added.
|
|
78
|
+
*/
|
|
79
|
+
describe('buildQueryDecorations', () => {
|
|
80
|
+
test.each([
|
|
81
|
+
['', 'empty'],
|
|
82
|
+
['#', 'a bare hash'],
|
|
83
|
+
['#important', 'a tag'],
|
|
84
|
+
['#a #b #c', 'several tags'],
|
|
85
|
+
['type:org.dxos.Person AND #important', 'a tag following another filter'],
|
|
86
|
+
['{ name: "x" } #tag', 'an object literal before a tag'],
|
|
87
|
+
['"# not a tag" #real', 'a hash inside a string'],
|
|
88
|
+
])('builds over %j (%s)', async (doc) => {
|
|
89
|
+
const view = await viewOf(doc);
|
|
90
|
+
expect(() => decorationsOf(view)).not.toThrow();
|
|
91
|
+
view.destroy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a hash inside a string is not treated as a tag', async () => {
|
|
95
|
+
const view = await viewOf('"# not a tag"', 0);
|
|
96
|
+
expect(decorationsOf(view).filter((range) => range.from === 1)).toHaveLength(0);
|
|
97
|
+
view.destroy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the chip is drawn in the tag hue', async () => {
|
|
101
|
+
const view = await viewOf('#important', 10);
|
|
102
|
+
// The hue lives in the widget's own DOM, so it is read off the rendered chip rather than a spec.
|
|
103
|
+
const classes = [...view.contentDOM.querySelectorAll('span')].map((el) => el.className);
|
|
104
|
+
expect(classes.some((className) => className.includes(hue))).toBe(true);
|
|
105
|
+
view.destroy();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tags are atomic, so text must never end up glued to one — and there has to be a position at the end
|
|
111
|
+
* of the document that is not adjacent to a chip.
|
|
112
|
+
*/
|
|
113
|
+
describe('spacing', () => {
|
|
114
|
+
test('typing immediately before a tag is separated from it', async () => {
|
|
115
|
+
const view = await viewOf('#important', 0);
|
|
116
|
+
view.dispatch({ changes: { from: 0, insert: 'a' }, selection: EditorSelection.cursor(1) });
|
|
117
|
+
expect(view.state.doc.toString()).toBe('a #important ');
|
|
118
|
+
expect(view.state.selection.main.head).toBe(1);
|
|
119
|
+
view.destroy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an insertion leaves a trailing space', async () => {
|
|
123
|
+
const view = await viewOf('#a', 2);
|
|
124
|
+
view.dispatch({ changes: { from: 2, insert: 'b' }, selection: EditorSelection.cursor(3) });
|
|
125
|
+
expect(view.state.doc.toString()).toBe('#ab ');
|
|
126
|
+
// The caret stays before the trailing space, so typing continues to extend the tag.
|
|
127
|
+
expect(view.state.selection.main.head).toBe(3);
|
|
128
|
+
view.destroy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the trailing space is not re-added after a deletion', async () => {
|
|
132
|
+
const view = await viewOf('#a ', 3);
|
|
133
|
+
view.dispatch({ changes: { from: 2, to: 3 } });
|
|
134
|
+
expect(view.state.doc.toString()).toBe('#a');
|
|
135
|
+
view.destroy();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('an empty document is left empty, so the placeholder shows', async () => {
|
|
139
|
+
const view = await viewOf('x', 1);
|
|
140
|
+
view.dispatch({ changes: { from: 0, to: 1 } });
|
|
141
|
+
expect(view.state.doc.toString()).toBe('');
|
|
142
|
+
view.destroy();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A mounted, focused view. Focus matters: the tag under the caret is only drawn as live text while the
|
|
148
|
+
* editor has it, so a blurred editor shows chips throughout.
|
|
149
|
+
*/
|
|
150
|
+
const viewOf = async (doc: string, head = doc.length) => {
|
|
151
|
+
const parent = document.createElement('div');
|
|
152
|
+
document.body.appendChild(parent);
|
|
153
|
+
const view = new EditorView({
|
|
154
|
+
state: EditorState.create({ doc, selection: EditorSelection.cursor(head), extensions: [query({ tags })] }),
|
|
155
|
+
parent,
|
|
156
|
+
});
|
|
157
|
+
view.contentDOM.dispatchEvent(new FocusEvent('focus'));
|
|
158
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
159
|
+
return view;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const decorationsOf = (view: EditorView) => {
|
|
163
|
+
const ranges: { from: number; to: number; atomic: boolean; class?: string }[] = [];
|
|
164
|
+
const cursor = buildQueryDecorations(view.state, { tags }).iter();
|
|
165
|
+
while (cursor.value) {
|
|
166
|
+
ranges.push({
|
|
167
|
+
from: cursor.from,
|
|
168
|
+
to: cursor.to,
|
|
169
|
+
atomic: !!cursor.value.spec.atomic,
|
|
170
|
+
class: cursor.value.spec.class,
|
|
171
|
+
});
|
|
172
|
+
cursor.next();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return ranges;
|
|
176
|
+
};
|