@dxos/react-ui-form 0.8.4-main.d05539e30a → 0.8.4-main.d9fc60f731
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/dist/lib/browser/index.mjs +1335 -561
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +2 -1
- package/dist/lib/browser/translations.mjs.map +2 -2
- package/dist/lib/node-esm/index.mjs +1335 -561
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +2 -1
- package/dist/lib/node-esm/translations.mjs.map +2 -2
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +2 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +35 -9
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +76 -16
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +14 -4
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +22 -10
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet.d.ts +8 -2
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +119 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
- package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +2 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts +4 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +2 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/index.d.ts +2 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/index.d.ts +3 -0
- package/dist/types/src/components/Form/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
- package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +3 -4
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +2 -2
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +2 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +2 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
- package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts +2 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +7 -8
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +4 -4
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +9 -0
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -1
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +11 -0
- package/dist/types/src/util/properties.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +37 -32
- package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
- package/src/components/FieldEditor/FieldEditor.test.tsx +4 -1
- package/src/components/FieldEditor/FieldEditor.tsx +19 -14
- package/src/components/Form/Form.stories.tsx +21 -27
- package/src/components/Form/Form.tsx +106 -19
- package/src/components/Form/FormField.tsx +85 -28
- package/src/components/Form/FormFieldComponent.tsx +75 -20
- package/src/components/Form/FormFieldSet.tsx +32 -3
- package/src/components/Form/FormTooltipsContext.ts +25 -0
- package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
- package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
- package/src/components/Form/Layout/FormLayout.test.ts +59 -0
- package/src/components/Form/Layout/FormLayout.tsx +251 -0
- package/src/components/Form/Layout/annotation.ts +38 -0
- package/src/components/Form/Layout/index.ts +7 -0
- package/src/components/Form/Layout/parser.test.ts +120 -0
- package/src/components/Form/Layout/parser.ts +167 -0
- package/src/components/Form/fields/ArrayField.tsx +61 -38
- package/src/components/Form/fields/DateField.tsx +101 -0
- package/src/components/Form/fields/GeoPointField.tsx +4 -1
- package/src/components/Form/fields/InlineRefField.tsx +146 -0
- package/src/components/Form/fields/MarkdownField.tsx +3 -3
- package/src/components/Form/fields/RefField.tsx +29 -23
- package/src/components/Form/fields/SelectField.tsx +17 -5
- package/src/components/Form/fields/SelectOptionField.tsx +4 -3
- package/src/components/Form/fields/TextAreaField.tsx +1 -1
- package/src/components/Form/fields/TupleField.tsx +2 -1
- package/src/components/Form/fields/index.ts +2 -0
- package/src/components/Form/index.ts +3 -0
- package/src/components/Form/meta-tags.test.ts +47 -0
- package/src/components/Form/meta-tags.ts +33 -0
- package/src/components/ObjectForm/ObjectForm.tsx +21 -26
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +2 -2
- package/src/components/ObjectPicker/ObjectPicker.tsx +2 -2
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +14 -26
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +20 -10
- package/src/components/ObjectProperties/ObjectProperties.tsx +26 -30
- package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
- package/src/components/ObjectTree/ObjectTree.tsx +190 -0
- package/src/components/ObjectTree/index.ts +5 -0
- package/src/components/Settings/Settings.tsx +2 -1
- package/src/components/Settings/SettingsItem.tsx +1 -1
- package/src/components/ViewEditor/ViewEditor.stories.tsx +35 -40
- package/src/components/ViewEditor/ViewEditor.test.tsx +11 -8
- package/src/components/ViewEditor/ViewEditor.tsx +29 -32
- package/src/components/index.ts +1 -0
- package/src/components/testing.tsx +4 -4
- package/src/translations.ts +2 -1
- package/src/util/properties.test.ts +67 -9
- package/src/util/properties.ts +36 -4
- package/src/vite-env.d.ts +10 -0
|
@@ -17,6 +17,7 @@ export const TupleField = ({
|
|
|
17
17
|
type,
|
|
18
18
|
readonly,
|
|
19
19
|
label,
|
|
20
|
+
jsonPath,
|
|
20
21
|
getStatus,
|
|
21
22
|
getValue,
|
|
22
23
|
onValueChange,
|
|
@@ -31,7 +32,7 @@ export const TupleField = ({
|
|
|
31
32
|
|
|
32
33
|
return (
|
|
33
34
|
<Input.Root validationValence={status}>
|
|
34
|
-
<FormFieldLabel error={error} readonly={readonly} label={label} />
|
|
35
|
+
<FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />
|
|
35
36
|
<div className={mx('grid gap-form-gap', gridCols[binding.length - 1])}>
|
|
36
37
|
{binding.map((prop) => (
|
|
37
38
|
<Input.TextInput
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
export * from './ArrayField';
|
|
6
6
|
export * from './BooleanField';
|
|
7
|
+
export * from './DateField';
|
|
7
8
|
export * from './GeoPointField';
|
|
9
|
+
export * from './InlineRefField';
|
|
8
10
|
export * from './MarkdownField';
|
|
9
11
|
export * from './NumberField';
|
|
10
12
|
export * from './RefField';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import * as SchemaAST from 'effect/SchemaAST';
|
|
7
|
+
import { describe, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Obj, Ref, Tag } from '@dxos/echo';
|
|
10
|
+
|
|
11
|
+
import { META_TAGS_KEY, withMetaTags } from './meta-tags';
|
|
12
|
+
|
|
13
|
+
// A feed-host schema whose own `tags` field is a `TagIndex` record (tagId -> objectId[]), not an
|
|
14
|
+
// array — mirrors plugin-commerce `Search`, plugin-inbox `Mailbox`, plugin-feed `Subscription`.
|
|
15
|
+
const TagIndexHost = Schema.Struct({
|
|
16
|
+
name: Schema.String.pipe(Schema.optional),
|
|
17
|
+
tags: Schema.Record({ key: Schema.String, value: Schema.Array(Obj.ID) }).pipe(Schema.optional),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const propertyNames = (schema: Schema.Schema.AnyNoContext): string[] =>
|
|
21
|
+
SchemaAST.getPropertySignatures(schema.ast).map((property) => property.name.toString());
|
|
22
|
+
|
|
23
|
+
describe('withMetaTags', () => {
|
|
24
|
+
test('keying meta-tags as `tags` collides with a schema-declared `tags` record (regression)', ({ expect }) => {
|
|
25
|
+
// The pre-fix shape: splice a `tags: Array<Ref<Tag>>` onto a schema that already declares `tags`.
|
|
26
|
+
// `Schema.extend` cannot merge an array with a record at the same key, hence the companion crash:
|
|
27
|
+
// "overlapping types at path: [\"tags\"]".
|
|
28
|
+
expect(() =>
|
|
29
|
+
Schema.Struct({ tags: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.optional) }).pipe(Schema.extend(TagIndexHost)),
|
|
30
|
+
).toThrow(/tags/i);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('splices meta-tags under `_tags` without colliding with a schema-declared `tags`', ({ expect }) => {
|
|
34
|
+
const schema = withMetaTags(TagIndexHost);
|
|
35
|
+
const names = propertyNames(schema);
|
|
36
|
+
expect(names).toContain(META_TAGS_KEY);
|
|
37
|
+
expect(names).toContain('tags');
|
|
38
|
+
expect(names).not.toContain('id');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('still splices meta-tags onto a schema with no `tags` field', ({ expect }) => {
|
|
42
|
+
const Plain = Schema.Struct({ name: Schema.String });
|
|
43
|
+
const names = propertyNames(withMetaTags(Plain));
|
|
44
|
+
expect(names).toContain(META_TAGS_KEY);
|
|
45
|
+
expect(names).toContain('name');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
|
|
7
|
+
import { Ref, Tag } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
import { omitId } from './Form';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Form-field key for the synthetic meta-tags array spliced into property/create forms.
|
|
13
|
+
*
|
|
14
|
+
* An object's tags live in `Obj.getMeta(obj).tags` (off-schema), so the form surfaces them as an
|
|
15
|
+
* extra field rather than reading the schema. The key is underscore-prefixed so it never collides
|
|
16
|
+
* with a `tags` field an object schema declares itself — e.g. a `TagIndex` record on a feed host,
|
|
17
|
+
* whose `tags` is `Record<tagId, objectId[]>`, not an array. Extending such a schema with a `tags`
|
|
18
|
+
* array threw "overlapping types at path: [\"tags\"]"; the disjoint `_tags` key avoids it. The
|
|
19
|
+
* human-facing label is restored via the `title` annotation on {@link withMetaTags}.
|
|
20
|
+
*/
|
|
21
|
+
export const META_TAGS_KEY = '_tags';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Splices the synthetic meta-tags array (an object's `meta.tags`, surfaced as {@link Tag} refs) onto
|
|
25
|
+
* a schema for editing and drops `id`. Keyed by {@link META_TAGS_KEY} so it never collides with a
|
|
26
|
+
* `tags` field the schema declares itself.
|
|
27
|
+
*/
|
|
28
|
+
export const withMetaTags = (schema: Schema.Schema.AnyNoContext) =>
|
|
29
|
+
omitId(
|
|
30
|
+
Schema.Struct({
|
|
31
|
+
[META_TAGS_KEY]: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.annotations({ title: 'Tags' }), Schema.optional),
|
|
32
|
+
}).pipe(Schema.extend(schema)),
|
|
33
|
+
);
|
|
@@ -2,21 +2,19 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import * as Schema from 'effect/Schema';
|
|
6
5
|
import React, { useCallback, useMemo } from 'react';
|
|
7
6
|
|
|
8
|
-
import {
|
|
7
|
+
import { Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
9
8
|
import { type JsonPath, splitJsonPath } from '@dxos/echo/internal';
|
|
10
9
|
import { invariant } from '@dxos/invariant';
|
|
11
10
|
import { HuePicker } from '@dxos/react-ui-pickers';
|
|
12
|
-
import { isNonNullable } from '@dxos/util';
|
|
13
11
|
|
|
14
12
|
import { translationKey } from '#translations';
|
|
15
13
|
|
|
16
|
-
import { Form, type FormFieldMap,
|
|
14
|
+
import { Form, type FormFieldMap, META_TAGS_KEY, withMetaTags } from '../Form';
|
|
17
15
|
|
|
18
16
|
export type ObjectFormProps = {
|
|
19
|
-
|
|
17
|
+
type: Type.AnyEntity;
|
|
20
18
|
object: Obj.Unknown;
|
|
21
19
|
};
|
|
22
20
|
|
|
@@ -33,51 +31,48 @@ const createFieldMap: FormFieldMap = {
|
|
|
33
31
|
},
|
|
34
32
|
};
|
|
35
33
|
|
|
36
|
-
export const ObjectForm = ({ object,
|
|
34
|
+
export const ObjectForm = ({ object, type }: ObjectFormProps) => {
|
|
37
35
|
const db = Obj.getDatabase(object);
|
|
38
36
|
const meta = Obj.getMeta(object);
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
omitId(
|
|
44
|
-
Schema.Struct({
|
|
45
|
-
tags: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.optional),
|
|
46
|
-
}).pipe(Schema.extend(schema)),
|
|
47
|
-
),
|
|
48
|
-
[schema],
|
|
49
|
-
);
|
|
37
|
+
// `meta.tags` already holds `Ref<Tag>`s (materialized by the database handler).
|
|
38
|
+
const tags = [...meta.tags];
|
|
39
|
+
const values = useMemo(() => ({ [META_TAGS_KEY]: tags, ...object }), [object, tags]);
|
|
40
|
+
const formSchema = useMemo(() => withMetaTags(Type.getSchema(type)), [type]);
|
|
50
41
|
|
|
51
|
-
const handleCreate = useCallback((
|
|
42
|
+
const handleCreate = useCallback((type: Type.AnyEntity, values: any) => {
|
|
52
43
|
invariant(db);
|
|
53
|
-
invariant(Type.
|
|
54
|
-
const newObject = db.add(Obj.make(
|
|
44
|
+
invariant(Type.isObject(type));
|
|
45
|
+
const newObject = db.add(Obj.make(type, values));
|
|
55
46
|
if (Obj.instanceOf(Tag.Tag, newObject)) {
|
|
56
47
|
Obj.update(object, (object) => {
|
|
57
|
-
Obj.getMeta(object).tags = [...
|
|
48
|
+
Obj.getMeta(object).tags = [...Obj.getMeta(object).tags, Ref.make(newObject)];
|
|
58
49
|
});
|
|
59
50
|
}
|
|
60
51
|
}, []);
|
|
61
52
|
|
|
62
53
|
// TODO(wittjosiah): Use FormRootProps type.
|
|
63
54
|
const handleChange = useCallback(
|
|
64
|
-
(
|
|
55
|
+
(
|
|
56
|
+
{ [META_TAGS_KEY]: metaTags, ...values }: any,
|
|
57
|
+
{ isValid, changed }: { isValid: boolean; changed: Record<JsonPath, boolean> },
|
|
58
|
+
) => {
|
|
65
59
|
if (!isValid) {
|
|
66
60
|
return;
|
|
67
61
|
}
|
|
68
62
|
|
|
69
63
|
const changedPaths = Object.keys(changed).filter((path) => changed[path as JsonPath]) as JsonPath[];
|
|
70
64
|
|
|
71
|
-
// Handle tags separately using Obj.update.
|
|
72
|
-
const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] ===
|
|
65
|
+
// Handle meta-tags separately using Obj.update.
|
|
66
|
+
const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] === META_TAGS_KEY);
|
|
73
67
|
if (hasTagsChange) {
|
|
74
68
|
Obj.update(object, (object) => {
|
|
75
|
-
|
|
69
|
+
// Copy so later in-place form mutations don't bypass the `Obj.update` boundary.
|
|
70
|
+
Obj.getMeta(object).tags = Array.isArray(metaTags) ? [...(metaTags as Ref.Ref<Tag.Tag>[])] : [];
|
|
76
71
|
});
|
|
77
72
|
}
|
|
78
73
|
|
|
79
74
|
// Handle other property changes.
|
|
80
|
-
const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !==
|
|
75
|
+
const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !== META_TAGS_KEY);
|
|
81
76
|
if (nonTagPaths.length > 0) {
|
|
82
77
|
Obj.update(object, () => {
|
|
83
78
|
for (const path of nonTagPaths) {
|
|
@@ -7,7 +7,7 @@ import * as Schema from 'effect/Schema';
|
|
|
7
7
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
8
8
|
import { expect, fn, userEvent, within } from 'storybook/test';
|
|
9
9
|
|
|
10
|
-
import { Filter, Obj } from '@dxos/echo';
|
|
10
|
+
import { Filter, Obj, Type } from '@dxos/echo';
|
|
11
11
|
import { random } from '@dxos/random';
|
|
12
12
|
import { useQuery } from '@dxos/react-client/echo';
|
|
13
13
|
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
@@ -28,7 +28,7 @@ const createPerson = () =>
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const omitId = Schema.omit<any, any, ['id']>('id');
|
|
31
|
-
const personSchema = omitId(Person.Person);
|
|
31
|
+
const personSchema = omitId(Type.getSchema(Person.Person));
|
|
32
32
|
|
|
33
33
|
// Mock functions for testing
|
|
34
34
|
const mockHandleSelect = fn();
|
|
@@ -23,7 +23,7 @@ export type RefOption = {
|
|
|
23
23
|
export type ObjectPickerContentProps = ThemedClassName<{
|
|
24
24
|
options: RefOption[];
|
|
25
25
|
selectedIds?: string[];
|
|
26
|
-
createOptionLabel?: [string, { ns: string }];
|
|
26
|
+
createOptionLabel?: [string, { ns: string | readonly string[] }];
|
|
27
27
|
createOptionIcon?: string;
|
|
28
28
|
createSchema?: Schema.Schema.AnyNoContext;
|
|
29
29
|
createInitialValuePath?: string;
|
|
@@ -167,7 +167,7 @@ const CreateItem = ({
|
|
|
167
167
|
onCreateItemSelect,
|
|
168
168
|
}: {
|
|
169
169
|
query: string;
|
|
170
|
-
createOptionLabel?: [string, { ns: string }];
|
|
170
|
+
createOptionLabel?: [string, { ns: string | readonly string[] }];
|
|
171
171
|
createOptionIcon: string;
|
|
172
172
|
onCreateItemSelect: (query: string) => void;
|
|
173
173
|
}) => {
|
|
@@ -7,7 +7,7 @@ import * as Schema from 'effect/Schema';
|
|
|
7
7
|
import React, { useEffect } from 'react';
|
|
8
8
|
import { expect, userEvent, within } from 'storybook/test';
|
|
9
9
|
|
|
10
|
-
import { Annotation, Filter, Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
10
|
+
import { DXN, Annotation, Filter, Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
11
11
|
import { FormInputAnnotation, LabelAnnotation } from '@dxos/echo/internal';
|
|
12
12
|
import { useQuery } from '@dxos/react-client/echo';
|
|
13
13
|
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
@@ -30,27 +30,21 @@ import { ObjectProperties } from './ObjectProperties';
|
|
|
30
30
|
const Author = Schema.Struct({
|
|
31
31
|
name: Schema.String,
|
|
32
32
|
}).pipe(
|
|
33
|
-
Type.object({
|
|
34
|
-
typename: 'org.dxos.test.author',
|
|
35
|
-
version: '0.1.0',
|
|
36
|
-
}),
|
|
37
33
|
LabelAnnotation.set(['name']),
|
|
38
34
|
Annotation.IconAnnotation.set({ icon: 'ph--user--regular', hue: 'blue' }),
|
|
35
|
+
Type.makeObject(DXN.make('org.dxos.test.author', '0.1.0')),
|
|
39
36
|
);
|
|
40
|
-
type Author =
|
|
37
|
+
type Author = Type.InstanceType<typeof Author>;
|
|
41
38
|
|
|
42
39
|
const Article = Schema.Struct({
|
|
43
40
|
title: Schema.String.pipe(Schema.optional),
|
|
44
41
|
authors: Schema.Array(Ref.Ref(Author)),
|
|
45
42
|
}).pipe(
|
|
46
|
-
Type.object({
|
|
47
|
-
typename: 'org.dxos.test.article',
|
|
48
|
-
version: '0.1.0',
|
|
49
|
-
}),
|
|
50
43
|
LabelAnnotation.set(['title']),
|
|
51
44
|
Annotation.IconAnnotation.set({ icon: 'ph--article--regular', hue: 'green' }),
|
|
45
|
+
Type.makeObject(DXN.make('org.dxos.test.article', '0.1.0')),
|
|
52
46
|
);
|
|
53
|
-
type Article =
|
|
47
|
+
type Article = Type.InstanceType<typeof Article>;
|
|
54
48
|
|
|
55
49
|
//
|
|
56
50
|
// `Note` mirrors the `Subscription.Feed` shape: a required `Ref` field
|
|
@@ -60,37 +54,31 @@ type Article = Schema.Schema.Type<typeof Article>;
|
|
|
60
54
|
// backing object and links it at create time. `cursor` is an optional hidden
|
|
61
55
|
// field included to exercise the same path for non-required hidden fields.
|
|
62
56
|
//
|
|
63
|
-
const NoteBacking = Schema.Struct({}).pipe(Type.
|
|
64
|
-
type NoteBacking =
|
|
57
|
+
const NoteBacking = Schema.Struct({}).pipe(Type.makeObject(DXN.make('org.dxos.test.noteBacking', '0.1.0')));
|
|
58
|
+
type NoteBacking = Type.InstanceType<typeof NoteBacking>;
|
|
65
59
|
|
|
66
60
|
const Note = Schema.Struct({
|
|
67
61
|
title: Schema.String,
|
|
68
62
|
cursor: Schema.String.pipe(FormInputAnnotation.set(false), Schema.optional),
|
|
69
63
|
backing: Ref.Ref(NoteBacking).pipe(FormInputAnnotation.set(false)),
|
|
70
64
|
}).pipe(
|
|
71
|
-
Type.object({
|
|
72
|
-
typename: 'org.dxos.test.note',
|
|
73
|
-
version: '0.1.0',
|
|
74
|
-
}),
|
|
75
65
|
LabelAnnotation.set(['title']),
|
|
76
66
|
Annotation.IconAnnotation.set({ icon: 'ph--note--regular', hue: 'amber' }),
|
|
77
67
|
FactoryAnnotation.set(((values: any) =>
|
|
78
68
|
Obj.make(Note, { ...values, backing: Ref.make(Obj.make(NoteBacking, {})) })) as FactoryFn),
|
|
69
|
+
Type.makeObject(DXN.make('org.dxos.test.note', '0.1.0')),
|
|
79
70
|
);
|
|
80
|
-
type Note =
|
|
71
|
+
type Note = Type.InstanceType<typeof Note>;
|
|
81
72
|
|
|
82
73
|
const Notebook = Schema.Struct({
|
|
83
74
|
name: Schema.String.pipe(Schema.optional),
|
|
84
75
|
notes: Schema.Array(Ref.Ref(Note)),
|
|
85
76
|
}).pipe(
|
|
86
|
-
Type.object({
|
|
87
|
-
typename: 'org.dxos.test.notebook',
|
|
88
|
-
version: '0.1.0',
|
|
89
|
-
}),
|
|
90
77
|
LabelAnnotation.set(['name']),
|
|
91
78
|
Annotation.IconAnnotation.set({ icon: 'ph--notebook--regular', hue: 'amber' }),
|
|
79
|
+
Type.makeObject(DXN.make('org.dxos.test.notebook', '0.1.0')),
|
|
92
80
|
);
|
|
93
|
-
type Notebook =
|
|
81
|
+
type Notebook = Type.InstanceType<typeof Notebook>;
|
|
94
82
|
|
|
95
83
|
//
|
|
96
84
|
// Stories.
|
|
@@ -288,9 +276,9 @@ export const CreateRefArrayPlay: Story = {
|
|
|
288
276
|
const canvas = within(canvasElement);
|
|
289
277
|
const body = within(document.body);
|
|
290
278
|
|
|
291
|
-
// ObjectProperties extends Article with the
|
|
292
|
-
// the form has two "Add item" buttons (Tags +
|
|
293
|
-
// — Authors comes after Tags in
|
|
279
|
+
// ObjectProperties extends Article with the synthetic `_tags` meta-tags
|
|
280
|
+
// array (labelled "Tags"), so the form has two "Add item" buttons (Tags +
|
|
281
|
+
// Authors). Pick the last one — Authors comes after Tags in rendered order.
|
|
294
282
|
const addButtons = await canvas.findAllByRole('button', { name: /add/i }, { timeout: 10_000 });
|
|
295
283
|
await userEvent.click(addButtons[addButtons.length - 1]);
|
|
296
284
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { composeStories } from '@storybook/react';
|
|
6
|
-
import { cleanup } from '@testing-library/react';
|
|
6
|
+
import { act, cleanup } from '@testing-library/react';
|
|
7
7
|
import { afterEach, describe, expect, test } from 'vitest';
|
|
8
8
|
|
|
9
9
|
import { Filter, Obj, Tag } from '@dxos/echo';
|
|
10
|
+
import { EID } from '@dxos/keys';
|
|
10
11
|
|
|
11
12
|
import { OBJECT_PROPERTIES_DEBUG_SYMBOL } from '../testing';
|
|
12
13
|
import * as stories from './ObjectProperties.stories';
|
|
@@ -20,8 +21,20 @@ const getDebug = (): ObjectPropertiesDebug => {
|
|
|
20
21
|
return debug!;
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Refs may be stored as legacy (`dxn:echo:@:<id>`, `dxn:echo:<space>:<id>`) or
|
|
26
|
+
* new (`echo:/<id>`, `echo://<space>/<id>`) form — extract and compare object id.
|
|
27
|
+
*/
|
|
28
|
+
const refTargetsObject = (uri: string, objectId: string): boolean => {
|
|
29
|
+
const parsed = EID.tryParse(uri);
|
|
30
|
+
return parsed !== undefined && EID.getEntityId(parsed) === objectId;
|
|
31
|
+
};
|
|
32
|
+
|
|
23
33
|
describe('ObjectProperties — inline create flow', () => {
|
|
24
|
-
afterEach(() => {
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
// Flush pending React scheduler work before teardown to prevent
|
|
36
|
+
// "window is not defined" errors from setImmediate callbacks firing after happy-dom cleanup.
|
|
37
|
+
await act(async () => {});
|
|
25
38
|
cleanup();
|
|
26
39
|
delete (window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL];
|
|
27
40
|
});
|
|
@@ -35,13 +48,11 @@ describe('ObjectProperties — inline create flow', () => {
|
|
|
35
48
|
const created = tags.find((tag) => tag.label === 'PinnedTag');
|
|
36
49
|
expect(created, 'new Tag.Tag with label "PinnedTag" should be in the database').toBeDefined();
|
|
37
50
|
|
|
38
|
-
// And `object.meta.tags` should contain a
|
|
39
|
-
// Compare by the DXN's object-id tail since refs may be stored as relative
|
|
40
|
-
// (`dxn:echo:@:<id>`) or absolute (`dxn:echo:<spaceId>:<id>`) forms.
|
|
51
|
+
// And `object.meta.tags` should contain a ref that resolves to that tag.
|
|
41
52
|
const meta = Obj.getMeta(object);
|
|
42
53
|
const createdId = (created as any).id as string;
|
|
43
|
-
const matched =
|
|
44
|
-
expect(matched, "meta.tags should contain a
|
|
54
|
+
const matched = meta.tags.some((ref) => refTargetsObject(ref.uri, createdId));
|
|
55
|
+
expect(matched, "meta.tags should contain a ref targeting the new tag's id").toBe(true);
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
test('non-Tag ref-array: Create + Save assigns the new ref to the array slot', { timeout: 30_000 }, async () => {
|
|
@@ -58,10 +69,9 @@ describe('ObjectProperties — inline create flow', () => {
|
|
|
58
69
|
// but never wires the new ref into the array slot.
|
|
59
70
|
const article = object as any;
|
|
60
71
|
expect(article.authors ?? [], 'article.authors should contain a single Ref').toHaveLength(1);
|
|
61
|
-
// Compare by DXN tail to be agnostic to relative vs absolute Ref form.
|
|
62
72
|
const createdId = (created as any).id as string;
|
|
63
73
|
expect(
|
|
64
|
-
article.authors[0]?.
|
|
74
|
+
refTargetsObject(article.authors[0]?.uri ?? '', createdId),
|
|
65
75
|
'article.authors[0] should reference the newly-created Author',
|
|
66
76
|
).toBe(true);
|
|
67
77
|
});
|
|
@@ -87,7 +97,7 @@ describe('ObjectProperties — inline create flow', () => {
|
|
|
87
97
|
expect(notebook.notes ?? [], 'notebook.notes should contain a single Ref').toHaveLength(1);
|
|
88
98
|
const createdId = (created as any).id as string;
|
|
89
99
|
expect(
|
|
90
|
-
notebook.notes[0]?.
|
|
100
|
+
refTargetsObject(notebook.notes[0]?.uri ?? '', createdId),
|
|
91
101
|
'notebook.notes[0] should reference the newly-created Note',
|
|
92
102
|
).toBe(true);
|
|
93
103
|
|
|
@@ -4,21 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import * as Function from 'effect/Function';
|
|
6
6
|
import * as Option from 'effect/Option';
|
|
7
|
-
import * as Schema from 'effect/Schema';
|
|
8
7
|
import React, { type PropsWithChildren, useCallback, useMemo } from 'react';
|
|
9
8
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
10
|
+
import { useType } from '@dxos/echo-react';
|
|
12
11
|
import { type JsonPath, splitJsonPath } from '@dxos/echo/internal';
|
|
13
12
|
import { invariant } from '@dxos/invariant';
|
|
13
|
+
import { composable, composableProps } from '@dxos/react-ui';
|
|
14
14
|
import { HuePicker } from '@dxos/react-ui-pickers';
|
|
15
15
|
import { FactoryAnnotation } from '@dxos/schema';
|
|
16
|
-
import { composable, composableProps } from '@dxos/ui-theme';
|
|
17
|
-
import { isNonNullable } from '@dxos/util';
|
|
18
16
|
|
|
19
17
|
import { translationKey } from '#translations';
|
|
20
18
|
|
|
21
|
-
import { Form, type FormFieldMap,
|
|
19
|
+
import { Form, type FormFieldMap, META_TAGS_KEY, withMetaTags } from '../Form';
|
|
22
20
|
|
|
23
21
|
export type ObjectPropertiesProps = PropsWithChildren<{ object: Obj.Unknown }>;
|
|
24
22
|
|
|
@@ -27,27 +25,29 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
|
|
|
27
25
|
({ children, object, ...props }, forwardedRef) => {
|
|
28
26
|
const db = Obj.getDatabase(object);
|
|
29
27
|
const meta = Obj.getMeta(object);
|
|
30
|
-
|
|
31
|
-
const
|
|
28
|
+
// `meta.tags` already holds `Ref<Tag>`s (materialized by the database handler).
|
|
29
|
+
const tags = [...meta.tags];
|
|
30
|
+
const values = useMemo(() => ({ [META_TAGS_KEY]: tags, ...object }), [object, tags]);
|
|
32
31
|
|
|
33
|
-
// Obj.
|
|
34
|
-
//
|
|
32
|
+
// Obj.getType fails for database-registered (dynamic) schemas due to DXN mismatch;
|
|
33
|
+
// useType queries by typename which correctly resolves both static and dynamic schemas.
|
|
35
34
|
const typename = Obj.getTypename(object) ?? undefined;
|
|
36
|
-
const
|
|
37
|
-
const
|
|
35
|
+
const typeFromRegistry = useType(db, typename);
|
|
36
|
+
const type = Obj.getType(object) ?? typeFromRegistry;
|
|
38
37
|
|
|
39
38
|
const formSchema = useMemo(() => {
|
|
40
39
|
return Function.pipe(
|
|
41
|
-
|
|
40
|
+
type,
|
|
42
41
|
Option.fromNullable,
|
|
43
|
-
Option.map((
|
|
42
|
+
Option.map((type) => Type.getSchema(type)),
|
|
43
|
+
Option.map((schema) => withMetaTags(schema)),
|
|
44
44
|
Option.getOrUndefined,
|
|
45
45
|
);
|
|
46
|
-
}, [
|
|
46
|
+
}, [type]);
|
|
47
47
|
|
|
48
48
|
// Persist a newly-created object referenced by one of the form's ref
|
|
49
49
|
// fields and return it. The calling RefField wires the new Ref into its
|
|
50
|
-
// own slot's form value; for the
|
|
50
|
+
// own slot's form value; for the synthetic `_tags` array, the resulting
|
|
51
51
|
// form change is then synced back to `Obj.getMeta(object).tags` by
|
|
52
52
|
// `handleChange` below — so Tag.Tag follows the same generic path as any
|
|
53
53
|
// other ref-array field, no type-specific branching required here.
|
|
@@ -56,11 +56,11 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
|
|
|
56
56
|
// values)` alone (e.g. types with a required ref to a backing object) can
|
|
57
57
|
// declare a `FactoryAnnotation` to take over construction.
|
|
58
58
|
const handleCreate = useCallback(
|
|
59
|
-
(
|
|
59
|
+
(type: Type.AnyEntity, values: any): Obj.Unknown => {
|
|
60
60
|
invariant(db);
|
|
61
|
-
invariant(Type.
|
|
62
|
-
const factory = Option.getOrUndefined(FactoryAnnotation.get(
|
|
63
|
-
const newObject = factory ? (factory(values) as Obj.Unknown) : Obj.make(
|
|
61
|
+
invariant(Type.isObject(type));
|
|
62
|
+
const factory = Option.getOrUndefined(FactoryAnnotation.get(Type.getSchema(type)));
|
|
63
|
+
const newObject = factory ? (factory(values) as Obj.Unknown) : Obj.make(type, values);
|
|
64
64
|
return db.add(newObject) as Obj.Unknown;
|
|
65
65
|
},
|
|
66
66
|
[db],
|
|
@@ -69,7 +69,7 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
|
|
|
69
69
|
// TODO(wittjosiah): Use FormRootProps type.
|
|
70
70
|
const handleChange = useCallback(
|
|
71
71
|
(
|
|
72
|
-
{
|
|
72
|
+
{ [META_TAGS_KEY]: metaTags, ...values }: any,
|
|
73
73
|
{ isValid, changed }: { isValid: boolean; changed: Record<JsonPath, boolean> },
|
|
74
74
|
) => {
|
|
75
75
|
if (!isValid) {
|
|
@@ -78,16 +78,17 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
|
|
|
78
78
|
|
|
79
79
|
const changedPaths = Object.keys(changed).filter((path) => changed[path as JsonPath]) as JsonPath[];
|
|
80
80
|
|
|
81
|
-
// Handle tags separately using Obj.update.
|
|
82
|
-
const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] ===
|
|
81
|
+
// Handle meta-tags separately using Obj.update.
|
|
82
|
+
const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] === META_TAGS_KEY);
|
|
83
83
|
if (hasTagsChange) {
|
|
84
84
|
Obj.update(object, (object) => {
|
|
85
|
-
|
|
85
|
+
// Copy so later in-place form mutations don't bypass the `Obj.update` boundary.
|
|
86
|
+
Obj.getMeta(object).tags = Array.isArray(metaTags) ? [...(metaTags as Ref.Ref<Tag.Tag>[])] : [];
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// Handle other property changes.
|
|
90
|
-
const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !==
|
|
91
|
+
const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !== META_TAGS_KEY);
|
|
91
92
|
if (nonTagPaths.length > 0) {
|
|
92
93
|
Obj.update(object, () => {
|
|
93
94
|
for (const path of nonTagPaths) {
|
|
@@ -141,8 +142,3 @@ const createFieldMap: FormFieldMap = {
|
|
|
141
142
|
);
|
|
142
143
|
},
|
|
143
144
|
};
|
|
144
|
-
|
|
145
|
-
// TODO(wittjosiah): Would be nice to control order when extending so this isn't always first/last.
|
|
146
|
-
const BaseSchema = Schema.Struct({
|
|
147
|
-
tags: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.optional),
|
|
148
|
-
});
|