@estiva-app/ui 0.47.0 → 0.49.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 +6 -0
- package/dist/BlockNodes.d.ts +73 -0
- package/dist/BlockNodes.d.ts.map +1 -0
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +141 -0
- package/dist/editor.js.map +4 -4
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +84 -25
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-field-outside-a-form.d.ts +35 -0
- package/dist/eslint/no-field-outside-a-form.d.ts.map +1 -0
- package/dist/eslint/rule-ids.d.ts +1 -1
- package/dist/eslint/rule-ids.d.ts.map +1 -1
- package/dist/gates/{chunk-DZVDOJGV.js → chunk-FIBZLYQI.js} +3 -2
- package/dist/gates/{chunk-DZVDOJGV.js.map → chunk-FIBZLYQI.js.map} +2 -2
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +79 -3
- package/src/BlockNodes.mdx +82 -0
- package/src/BlockNodes.stories.tsx +79 -0
- package/src/BlockNodes.test.tsx +117 -0
- package/src/BlockNodes.tsx +242 -0
- package/src/DialogShell.stories.tsx +18 -0
- package/src/DialogShell.tsx +4 -2
- package/src/Form.mdx +4 -0
- package/src/editor.ts +10 -0
- package/src/eslint/index.test.ts +6 -4
- package/src/eslint/index.ts +4 -0
- package/src/eslint/no-field-outside-a-form.test.ts +63 -0
- package/src/eslint/no-field-outside-a-form.ts +92 -0
- package/src/eslint/rule-ids.ts +1 -0
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
- package/src/registry/registry.test.ts +10 -8
- package/stories/Choosing.mdx +1 -0
package/src/Form.mdx
CHANGED
|
@@ -22,6 +22,10 @@ is switched off at once.
|
|
|
22
22
|
- One field that saves itself as you leave it → **EditableText**.
|
|
23
23
|
- A field that filters a list as you type → **SearchInput**; nothing is sent.
|
|
24
24
|
|
|
25
|
+
A `Field`, `TextInput` or `Textarea` with no `Form` around it is refused by
|
|
26
|
+
the gate (`estiva/no-field-outside-a-form`). A question with a field in it is
|
|
27
|
+
**DialogShell** with a form, never **ConfirmDialog**.
|
|
28
|
+
|
|
25
29
|
## How
|
|
26
30
|
|
|
27
31
|
```tsx
|
package/src/editor.ts
CHANGED
|
@@ -9,3 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
|
+
export {
|
|
13
|
+
BlockId,
|
|
14
|
+
UnknownBlock,
|
|
15
|
+
ReferenceNode,
|
|
16
|
+
AttachmentNode,
|
|
17
|
+
type ReferenceNodeOptions,
|
|
18
|
+
type ReferenceViewProps,
|
|
19
|
+
type AttachmentNodeOptions,
|
|
20
|
+
type AttachmentViewProps,
|
|
21
|
+
} from './BlockNodes'
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ describe('the plugin object', () => {
|
|
|
41
41
|
'no-handmade-empty-state',
|
|
42
42
|
'no-native-title',
|
|
43
43
|
'no-copied-look',
|
|
44
|
+
'no-field-outside-a-form',
|
|
44
45
|
'raw-element-outside-a-wrapper',
|
|
45
46
|
'no-hand-rolled-behaviour',
|
|
46
47
|
'component-has-a-page',
|
|
@@ -58,12 +59,12 @@ describe('the plugin object', () => {
|
|
|
58
59
|
* change it deliberately, here.
|
|
59
60
|
*/
|
|
60
61
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
61
|
-
const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error' }
|
|
62
|
+
const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error', 'estiva/no-field-outside-a-form': 'error' }
|
|
62
63
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
63
64
|
// The copied look warns in recommended (Katerina, 13 September), and strict makes it an error.
|
|
64
65
|
expect(estiva.configs.recommended.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'warn' })
|
|
65
66
|
expect(estiva.configs.strict.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'error' })
|
|
66
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
|
|
67
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look', 'estiva/no-field-outside-a-form'])
|
|
67
68
|
})
|
|
68
69
|
|
|
69
70
|
/**
|
|
@@ -154,8 +155,8 @@ describe('countGates', () => {
|
|
|
154
155
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
155
156
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
156
157
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
157
|
-
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
158
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
158
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none })
|
|
159
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none })
|
|
159
160
|
})
|
|
160
161
|
|
|
161
162
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -180,6 +181,7 @@ describe('countGates', () => {
|
|
|
180
181
|
'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 },
|
|
181
182
|
'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 },
|
|
182
183
|
'estiva/no-copied-look': { errors: 0, warnings: 0, escapes: 0 },
|
|
184
|
+
'estiva/no-field-outside-a-form': { errors: 0, warnings: 0, escapes: 0 },
|
|
183
185
|
},
|
|
184
186
|
disabled: [],
|
|
185
187
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
|
23
23
|
import { noHandmadeEmptyState } from './no-handmade-empty-state'
|
|
24
24
|
import { noHandmadeHeader } from './no-handmade-header'
|
|
25
25
|
import { noCopiedLook } from './no-copied-look'
|
|
26
|
+
import { noFieldOutsideAForm } from './no-field-outside-a-form'
|
|
26
27
|
import { noNativeTitle } from './no-native-title'
|
|
27
28
|
import { noRawElement } from './no-raw-element'
|
|
28
29
|
import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
|
|
@@ -35,6 +36,7 @@ export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
|
|
|
35
36
|
export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
|
|
36
37
|
export { PARTS_WITH_A_TITLE } from './no-native-title'
|
|
37
38
|
export { MIN_COVER, MIN_SHARED } from './looks-of'
|
|
39
|
+
export { FIELD_PARTS } from './no-field-outside-a-form'
|
|
38
40
|
|
|
39
41
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
40
42
|
|
|
@@ -56,6 +58,7 @@ const appRules = {
|
|
|
56
58
|
'no-handmade-empty-state': noHandmadeEmptyState,
|
|
57
59
|
'no-native-title': noNativeTitle,
|
|
58
60
|
'no-copied-look': noCopiedLook,
|
|
61
|
+
'no-field-outside-a-form': noFieldOutsideAForm,
|
|
59
62
|
} satisfies Record<AppRuleName, unknown>
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -119,6 +122,7 @@ plugin.configs.recommended = {
|
|
|
119
122
|
[`${PLUGIN_KEY}/no-native-title`]: 'error',
|
|
120
123
|
// A warning, never an error (Katerina, 13 September): likeness is a judgement.
|
|
121
124
|
[`${PLUGIN_KEY}/no-copied-look`]: 'warn',
|
|
125
|
+
[`${PLUGIN_KEY}/no-field-outside-a-form`]: 'error',
|
|
122
126
|
},
|
|
123
127
|
}
|
|
124
128
|
plugin.configs.strict = {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { RuleTester } from 'eslint'
|
|
5
|
+
import { parser } from 'typescript-eslint'
|
|
6
|
+
import { describe, it } from 'vitest'
|
|
7
|
+
import { noFieldOutsideAForm } from './no-field-outside-a-form'
|
|
8
|
+
|
|
9
|
+
RuleTester.describe = describe
|
|
10
|
+
RuleTester.it = it
|
|
11
|
+
RuleTester.itOnly = it.only
|
|
12
|
+
|
|
13
|
+
const tester = new RuleTester({
|
|
14
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
15
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** An app on disk, so the app's re-exports are followed to their part, as Peek's are. */
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'no-field-outside-a-form-'))
|
|
20
|
+
const write = (path: string, text: string) => {
|
|
21
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
22
|
+
writeFileSync(join(root, path), text)
|
|
23
|
+
}
|
|
24
|
+
write('app/package.json', '{ "name": "app" }')
|
|
25
|
+
write('app/src/components/ui/Field.tsx', "export { Field } from '@estiva-app/ui'\n")
|
|
26
|
+
write('app/src/components/ui/Textarea.tsx', "export { Textarea } from '@estiva-app/ui'\n")
|
|
27
|
+
write('app/src/components/ui/ConfirmDialog.tsx', "export { ConfirmDialog } from '@estiva-app/ui'\n")
|
|
28
|
+
const app = join(root, 'app/src/Probe.tsx')
|
|
29
|
+
|
|
30
|
+
const ui = (...parts: string[]) => `import { ${parts.join(', ')} } from '@estiva-app/ui'\n`
|
|
31
|
+
const component = (body: string, imports = '') => `${imports}export function Probe({ text, set }: { text: string; set: (v: string) => void }) {\n return (\n${body}\n )\n}\n`
|
|
32
|
+
const loose = (part: string) => [{ messageId: 'loose', data: { part } }]
|
|
33
|
+
|
|
34
|
+
tester.run('no-field-outside-a-form', noFieldOutsideAForm, {
|
|
35
|
+
valid: [
|
|
36
|
+
{
|
|
37
|
+
name: "Peek's ResolveDialog: a DialogShell with a Form",
|
|
38
|
+
code: component('<DialogShell title="Resolve" onClose={() => {}}>\n<Form onSubmit={() => {}}>\n<Field label="Message"><Textarea value={text} onChange={(e) => set(e.target.value)} /></Field>\n</Form>\n</DialogShell>', ui('DialogShell', 'Form', 'Field', 'Textarea')),
|
|
39
|
+
},
|
|
40
|
+
{ name: 'a one-line field with its button', code: component('<Form onSubmit={() => {}}><TextInput value={text} onChange={(e) => set(e.target.value)} /></Form>', ui('Form', 'TextInput')) },
|
|
41
|
+
{ name: 'fields drawn from a list, inside the form', code: component("<Form onSubmit={() => {}}>{['a', 'b'].map((k) => <Field key={k} label={k}><TextInput value={text} /></Field>)}</Form>", ui('Form', 'Field', 'TextInput')) },
|
|
42
|
+
{ name: "the palette's form level is a Form (UIG-29)", code: component('<CommandPaletteForm submitLabel="Create" onSubmit={() => {}}><Field label="Title"><TextInput value={text} /></Field></CommandPaletteForm>', ui('CommandPaletteForm', 'Field', 'TextInput')) },
|
|
43
|
+
{ name: 'a field that saves itself as you leave it', code: component('<EditableText value={text} onCommit={set} />', ui('EditableText')) },
|
|
44
|
+
{ name: 'a field that filters a list', code: component('<SearchInput value={text} onChange={set} />', ui('SearchInput')) },
|
|
45
|
+
{
|
|
46
|
+
name: "a control handed to a Field from a function of its own: the Field is judged (Peek's ActionFormPanel)",
|
|
47
|
+
code: `${ui('Field', 'TextInput')}const control = (text: string) => <TextInput value={text} />\nexport function Probe({ text }: { text: string }) {\n return (\n<>\n{/* @estiva-escape(no-field-outside-a-form): the launcher's CommandPaletteForm holds these fields */}\n<Field label="Title">{control(text)}</Field>\n</>\n )\n}\n`,
|
|
48
|
+
},
|
|
49
|
+
{ name: "the app's own component is its own business", code: component('<ResolutionField value={text} />', "import { ResolutionField } from './ResolutionField'\n") },
|
|
50
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape(no-field-outside-a-form): the caller wraps this in its Form, in StartTopicDialog */}\n<Field label="Title"><TextInput value={text} /></Field>\n</>', ui('Field', 'TextInput')) },
|
|
51
|
+
],
|
|
52
|
+
invalid: [
|
|
53
|
+
{
|
|
54
|
+
name: "Peek's ArchiveDialog at peek 3c4b799: a resolution in a ConfirmDialog",
|
|
55
|
+
filename: app,
|
|
56
|
+
code: component('<ConfirmDialog title="Archive?" confirmLabel="Archive topic" onClose={() => {}} onConfirm={() => {}}>\n<p>This hides the topic.</p>\n<Field label="Resolution (optional)">\n<Textarea value={text} rows={3} onChange={(e) => set(e.target.value)} />\n</Field>\n</ConfirmDialog>', "import { ConfirmDialog } from '@/components/ui/ConfirmDialog'\nimport { Field } from '@/components/ui/Field'\nimport { Textarea } from '@/components/ui/Textarea'\n"),
|
|
57
|
+
errors: loose('Field'),
|
|
58
|
+
},
|
|
59
|
+
{ name: 'a bare text input with no form', code: component('<TextInput value={text} onChange={(e) => set(e.target.value)} />', ui('TextInput')), errors: loose('TextInput') },
|
|
60
|
+
{ name: 'a textarea in a DialogShell with no form', code: component('<DialogShell title="Note" onClose={() => {}}><Textarea value={text} /></DialogShell>', ui('DialogShell', 'Textarea')), errors: loose('Textarea') },
|
|
61
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape(no-field-outside-a-form) */}\n<Field label="Title"><TextInput value={text} /></Field>\n</>', ui('Field', 'TextInput')), errors: [{ messageId: 'escapeWithoutReason' }, ...loose('Field')] },
|
|
62
|
+
],
|
|
63
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } from './no-restyled-part'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The parts a person types into to send something. `Field` is the labelled
|
|
7
|
+
* row; `TextInput` and `Textarea` are judged on their own only in a file that
|
|
8
|
+
* draws no `Field`, so a field is reported once, at its label.
|
|
9
|
+
*/
|
|
10
|
+
export const FIELD_PARTS = ['Field', 'TextInput', 'Textarea'] as const
|
|
11
|
+
|
|
12
|
+
/** The parts that are a `Form`: the package's own, and the palette's form level, which is one (UIG-29). */
|
|
13
|
+
export const FORM_PARTS = ['Form', 'CommandPaletteForm'] as const
|
|
14
|
+
|
|
15
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
16
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
17
|
+
node?.type === 'JSXIdentifier' || node?.type === 'Identifier' ? (node.name as string) : undefined
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A field outside a `Form` (Katerina, 1 October, after Peek's and Ship's
|
|
21
|
+
* Archive dialog: a resolution typed into a `ConfirmDialog`, sent by its
|
|
22
|
+
* button, with no form around it — Ctrl+Enter did nothing, and the field
|
|
23
|
+
* stayed open while it archived).
|
|
24
|
+
*
|
|
25
|
+
* `Form` owns what a field needs to be sent: Enter and Ctrl+Enter, `busy`
|
|
26
|
+
* switching every field off at once, and where focus goes after. A field
|
|
27
|
+
* outside one has none of it, and the dialog around it is the wrong one: a
|
|
28
|
+
* question with a field in it is a `DialogShell` with a `Form`
|
|
29
|
+
* (ConfirmDialog's page, **When not**).
|
|
30
|
+
*
|
|
31
|
+
* Judged: a `Field`, a `TextInput` or a `Textarea` of the package (or an
|
|
32
|
+
* app's re-export or wrapper of one) with no `Form` around it in the same
|
|
33
|
+
* file. Not judged, on purpose:
|
|
34
|
+
*
|
|
35
|
+
* - `EditableText` and `SearchInput`: one saves itself as you leave it, the
|
|
36
|
+
* other filters a list, and neither is sent (Form's page, **When not**);
|
|
37
|
+
* - a `TextInput` or `Textarea` in a file that draws a `Field`: the `Field`
|
|
38
|
+
* is reported, and a control handed to it from a function of its own (a
|
|
39
|
+
* `switch` over field kinds) cannot be followed to it;
|
|
40
|
+
* - the app's own components: a component that draws a field for a form its
|
|
41
|
+
* caller holds says so with an escape on the field, naming that caller.
|
|
42
|
+
*/
|
|
43
|
+
export const noFieldOutsideAForm: Rule.RuleModule = {
|
|
44
|
+
meta: {
|
|
45
|
+
type: 'problem',
|
|
46
|
+
docs: { description: 'A field with no Form around it' },
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
loose:
|
|
50
|
+
'`{{part}}` has no `Form` around it, so nothing owns sending it: Enter and Ctrl+Enter, `busy`, and focus after. Put it in `Form` from @estiva-app/ui; in a dialog, that is `DialogShell` with a `Form`, never `ConfirmDialog` (Primitives/Form says how).',
|
|
51
|
+
...ESCAPE_MESSAGES,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
56
|
+
let locals: Map<string, PartBinding | 'namespace'> | undefined
|
|
57
|
+
const loose: { opening: Node; part: string }[] = []
|
|
58
|
+
let drawsAField = false
|
|
59
|
+
const partOf = (element: Node): string | undefined => {
|
|
60
|
+
const tag = child(child(element, 'openingElement') as Node, 'name')
|
|
61
|
+
const name = nameOf(tag)
|
|
62
|
+
if (!name || /^[a-z]/.test(name)) return undefined
|
|
63
|
+
const binding = locals && bindingOfTag(tag, locals)
|
|
64
|
+
return binding && binding.props === 'all' ? binding.part : undefined
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
Program(program) {
|
|
68
|
+
locals = partsOfFile(context.filename, program as unknown as Node, parser)
|
|
69
|
+
},
|
|
70
|
+
JSXElement(ruleNode: Rule.Node) {
|
|
71
|
+
const element = ruleNode as unknown as Node
|
|
72
|
+
const part = partOf(element)
|
|
73
|
+
if (!part || !(FIELD_PARTS as readonly string[]).includes(part)) return
|
|
74
|
+
if (part === 'Field') drawsAField = true
|
|
75
|
+
for (let up = element.parent as Node | undefined; up; up = up.parent as Node | undefined) {
|
|
76
|
+
if (up.type !== 'JSXElement') continue
|
|
77
|
+
const around = partOf(up)
|
|
78
|
+
if (around && (FORM_PARTS as readonly string[]).includes(around)) return
|
|
79
|
+
if (around === 'Field') return
|
|
80
|
+
}
|
|
81
|
+
loose.push({ opening: child(element, 'openingElement') as Node, part })
|
|
82
|
+
},
|
|
83
|
+
'Program:exit'() {
|
|
84
|
+
for (const { opening, part } of loose) {
|
|
85
|
+
if (part !== 'Field' && drawsAField) continue
|
|
86
|
+
if (isEscaped(context, opening)) continue
|
|
87
|
+
context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'loose', data: { part } })
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
package/src/eslint/rule-ids.ts
CHANGED
|
@@ -72,7 +72,7 @@ describe('create-estiva-app', () => {
|
|
|
72
72
|
|
|
73
73
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
74
74
|
const count = JSON.parse(files['.gates-count.json'])
|
|
75
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
75
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
76
76
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
|
|
77
77
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
78
78
|
})
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -194,7 +194,7 @@ describe('writeGateCount', () => {
|
|
|
194
194
|
const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
|
|
195
195
|
expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
|
|
196
196
|
expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
|
|
197
|
-
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
|
|
197
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look', 'estiva/no-field-outside-a-form'])
|
|
198
198
|
|
|
199
199
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
200
200
|
|
|
@@ -25,6 +25,8 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
|
|
|
25
25
|
*/
|
|
26
26
|
const root = process.cwd()
|
|
27
27
|
const registry = buildRegistry({ root })
|
|
28
|
+
/** Editor extensions: named like components by the catalogue, and not React components. */
|
|
29
|
+
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode']
|
|
28
30
|
const entry = (name: string): RegistryEntry => {
|
|
29
31
|
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
30
32
|
if (!found) throw new Error(`no entry for ${name}`)
|
|
@@ -46,19 +48,19 @@ describe('the registry builds', () => {
|
|
|
46
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
47
49
|
})
|
|
48
50
|
|
|
49
|
-
it('counts
|
|
51
|
+
it('counts 104 names over 64 files: 90 components, 13 helpers and 1 hook (the block nodes joined on 30 September, MAN-9)', () => {
|
|
50
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
51
|
-
//
|
|
52
|
-
// documented on SelectionToolbar's
|
|
53
|
+
// The five `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
|
+
// editor extensions, documented on SelectionToolbar's and BlockNodes' pages.
|
|
53
55
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
54
|
-
expect(kinds).toEqual({ component:
|
|
55
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
56
|
+
expect(kinds).toEqual({ component: 90, helper: 13, hook: 1 })
|
|
57
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(64)
|
|
56
58
|
})
|
|
57
59
|
|
|
58
60
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
59
61
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
60
62
|
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(61)
|
|
61
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(43)
|
|
62
64
|
})
|
|
63
65
|
|
|
64
66
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
|
@@ -323,8 +325,8 @@ describe('a second parser agrees', async () => {
|
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
expect(differences).toEqual([])
|
|
326
|
-
//
|
|
327
|
-
expect(seen).toBe(registry.entries.filter((one) => one.kind === 'component' && one.name
|
|
328
|
+
// Named like components and are editor extensions: nothing for docgen to read.
|
|
329
|
+
expect(seen).toBe(registry.entries.filter((one) => one.kind === 'component' && !EDITOR_EXTENSIONS.includes(one.name)).length)
|
|
328
330
|
})
|
|
329
331
|
})
|
|
330
332
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -68,6 +68,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
68
68
|
| One window to find something and act on it, opened by a shortcut | **CommandPalette** — rows, forms and written answers, one level at a time |
|
|
69
69
|
| A list that opens as you type in an editor — `/` for commands, `@` for people | **SuggestionMenu** — the editor keeps the caret and hands it the arrow keys; `suggestionPopup` from `@estiva-app/ui/editor` wires it to a Tiptap plugin |
|
|
70
70
|
| Bold, italic and the like over selected text in an editor | **SelectionToolbar**, from `@estiva-app/ui/editor` — it follows the selection by itself |
|
|
71
|
+
| An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
|
|
71
72
|
| Anything with a title, body and footer | **DialogShell** |
|
|
72
73
|
| "Are you sure?" | **ConfirmDialog** |
|
|
73
74
|
| A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |
|