Skip to content

Commit

Permalink
feat: autocomplete attributes on plain html elements FUI-1227 (#32)
Browse files Browse the repository at this point in the history
* feat: script to get html attribute data and check in that data FUI-1227
* feat expose plain html attributes on global data api FUI-1227
* feat: autocomplete attribute for plain html elements FUI-1227
* chore: add jest coverage command FUI-1227
* test: update unit tests FUI-1227
* fix: html attr resource mislabling boolean attribute FUI-1227
* fix: add missing header plain html tag names FUI-1227
* chore: add running tests to pr template FUI-1227
* chore: add link to mozilla page in comment FUI-1227
  • Loading branch information
matteematt authored Jul 19, 2023
1 parent 82faffa commit 8f652bb
Show file tree
Hide file tree
Showing 13 changed files with 1,773 additions and 24 deletions.
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Update test steps to match your PR:
```
1. Checkout branch
2. `npm run bootstrap`
3. `npm run test:unit`
```

> These testing instructions assume that you've already setup the LSP in your IDE with the `example` app. If you haven't then follow the instructions in the `README.md`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"lint:eslint:all": "cross-env TIMING=1 node --max_old_space_size=4096 ./node_modules/eslint/bin/eslint.js \"./**/*.{ts,js}\"",
"lint:eslint:all:fix": "node --max_old_space_size=4096 ./node_modules/eslint/bin/eslint.js \"./**/*.{ts,js}\" --fix",
"test:unit": "jest",
"test:unit:coverage": "jest --coverage",
"test:unit:debug": "node --inspect-brk node_modules/jest/bin/jest.js",
"test:unit:verbose": "cross-env TEST_LOG=1 jest",
"test:unit:watch": "jest --watchAll"
Expand Down
12 changes: 12 additions & 0 deletions src/jest/global-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export const getGDServiceFromStubbedResource = () => {
getHTMLElementTags() {
return ['div', 'img', 'p', 'a'];
},
getHTMLAttributes(tagName) {
if (tagName !== 'a') {
return [];
}
return [
{
name: 'href',
description: 'The URL of a linked resource.',
type: 'string',
},
];
},
};

return new GlobalDataServiceImpl(getLogger(), resource);
Expand Down
118 changes: 118 additions & 0 deletions src/plugin/completions/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,44 @@ describe('getCompletionsAtPosition', () => {
]);
});

it('Returns completions for the custom element and html tags after an incomplete or unknown html element', () => {
const service = getCompletionsService();
const context = html`<im `;
const position = {
line: 0,
character: 4,
};
const typeAndParam = getTokenTypeWithInfo(context, position);

const completions = service.getCompletionsAtPosition(baseCompletionInfo, {
context,
position,
typeAndParam,
});

expect(completions.entries).toEqual([
{
insertText: 'custom-element></custom-element>',
kind: 'type',
name: 'custom-element',
sortText: 'custom-element',
labelDetails: {
description: 'src/components/avatar/avatar.ts',
},
},
{
insertText: 'no-attr></no-attr>',
kind: 'type',
name: 'no-attr',
sortText: 'custom-element',
labelDetails: {
description: 'pkg',
},
},
...globalDataNameCompletions,
]);
});

it('Returns attribute completions when past a valid custom element name which has defined attributes', () => {
const service = getCompletionsService();
const context = html`<custom-element `;
Expand Down Expand Up @@ -204,6 +242,37 @@ describe('getCompletionsAtPosition', () => {
]);
});

it('Returns attribute completions when past a valid plain element name which has defined attributes', () => {
const service = getCompletionsService();
const context = html`<a `;
const position = {
line: 0,
character: 3,
};
const typeAndParam = getTokenTypeWithInfo(context, position);

const completions = service.getCompletionsAtPosition(baseCompletionInfo, {
context,
position,
typeAndParam,
});

expect(completions.entries).toEqual([
{
insertText: 'href=""',
kind: 'parameter',
kindModifiers: '',
labelDetails: {
description: '[attr] HTML Element',
detail: ' string',
},
name: 'href',
sortText: 'a',
},
...globalDataAttributeAssersions,
]);
});

it('Returns attribute completions when writing an attribute', () => {
const service = getCompletionsService();
const context = html`<custom-element col`;
Expand Down Expand Up @@ -246,6 +315,37 @@ describe('getCompletionsAtPosition', () => {
]);
});

it('Returns attribute completions when past a valid plain element name and writing a known attribute', () => {
const service = getCompletionsService();
const context = html`<a hr`;
const position = {
line: 0,
character: 5,
};
const typeAndParam = getTokenTypeWithInfo(context, position);

const completions = service.getCompletionsAtPosition(baseCompletionInfo, {
context,
position,
typeAndParam,
});

expect(completions.entries).toEqual([
{
insertText: 'href=""',
kind: 'parameter',
kindModifiers: '',
labelDetails: {
description: '[attr] HTML Element',
detail: ' string',
},
name: 'href',
sortText: 'a',
},
...globalDataAttributeAssersions,
]);
});

it('Returns attribute completions when writing an attribute inside of a finished tag', () => {
const service = getCompletionsService();
const context = html`
Expand Down Expand Up @@ -308,6 +408,24 @@ describe('getCompletionsAtPosition', () => {
expect(completions.entries).toEqual([...globalDataAttributeAssersions]);
});

it('Returns only the global attribute completions when we try and complete attributes on a plain element with no attributes', () => {
const service = getCompletionsService();
const context = html`<div `;
const position = {
line: 0,
character: 5,
};
const typeAndParam = getTokenTypeWithInfo(context, position);

const completions = service.getCompletionsAtPosition(baseCompletionInfo, {
context,
position,
typeAndParam,
});

expect(completions.entries).toEqual([...globalDataAttributeAssersions]);
});

it('Returns name completions when we try and complete attributes on an unknown custom element', () => {
const service = getCompletionsService();
const context = html`<unknown-element `;
Expand Down
39 changes: 19 additions & 20 deletions src/plugin/completions/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Services } from '../utils/services.types';
import {
CompletionCtx,
CompletionsService,
constructElementAttrCompletion,
constructGlobalAriaCompletion,
constructGlobalAttrCompletion,
constructGlobalEventCompletion,
Expand Down Expand Up @@ -36,11 +37,17 @@ export class CoreCompletionsServiceImpl implements CompletionsService {
break;

case 'element-attribute':
if (!this.services.customElements.customElementKnown(params.tagName)) {
const isUnknownCE =
params.isCustomElement &&
!this.services.customElements.customElementKnown(params.tagName);
const isUnknownPlainElement =
!params.isCustomElement &&
!this.services.globalData.getHTMLElementTags().includes(params.tagName);
if (isUnknownCE || isUnknownPlainElement) {
baseEntries = this.getTagCompletions();
break;
}
baseEntries = this.getAttributeCompletions(params.tagName);
baseEntries = this.getAttributeCompletions(params.tagName, params.isCustomElement);
break;

case 'none':
Expand All @@ -56,9 +63,15 @@ export class CoreCompletionsServiceImpl implements CompletionsService {
};
}

private getAttributeCompletions(tagName: string): CompletionEntry[] {
const attrs = this.services.customElements.getCEAttributes(tagName);
this.logger.log(`element-attribute: ${tagName}, ${JSON.stringify(attrs)}`);
private getAttributeCompletions(tagName: string, isCustomElement: boolean): CompletionEntry[] {
const attrs = isCustomElement
? this.services.customElements.getCEAttributes(tagName)
: this.services.globalData.getHTMLAttributes(tagName);
this.logger.log(
`${
isCustomElement ? 'custom-element' : 'html-element'
}-attribute: ${tagName}, ${JSON.stringify(attrs)}`
);

const globalAttrs = getStore(this.logger).TSUnsafeGetOrAdd('global-attributes', () =>
this.services.globalData
Expand All @@ -68,21 +81,7 @@ export class CoreCompletionsServiceImpl implements CompletionsService {
.concat(this.services.globalData.getEvents().map(constructGlobalEventCompletion))
);

return attrs
.map(
({ name, type, referenceClass, deprecated }): CompletionEntry => ({
name,
insertText: `${name}${type === 'boolean' ? '' : '=""'}`,
kind: ScriptElementKind.parameterElement,
sortText: 'a',
labelDetails: {
description: (deprecated ? '(deprecated) ' : '') + `[attr] ${referenceClass}`,
detail: ` ${type}`,
},
kindModifiers: deprecated ? 'deprecated' : '',
})
)
.concat(globalAttrs);
return attrs.map(constructElementAttrCompletion).concat(globalAttrs);
}

private getTagCompletions(): CompletionEntry[] {
Expand Down
45 changes: 45 additions & 0 deletions src/plugin/completions/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { LineAndCharacter } from 'typescript/lib/tsserverlibrary';
import { TemplateContext } from 'typescript-template-language-service-decorator';
import { html } from '../../jest/utils';
import {
constructElementAttrCompletion,
constructGlobalAriaCompletion,
constructGlobalAttrCompletion,
constructGlobalEventCompletion,
} from './helpers';
import { getTokenTypeWithInfo } from '../utils';
import { PlainElementAttribute } from '../global-data/global-data.types';
import { CustomElementAttribute } from '../custom-elements/custom-elements.types';

describe('constructGlobalAriaCompletion', () => {
it('returns CompletionEntry', () => {
Expand Down Expand Up @@ -78,3 +81,45 @@ describe('constructGlobalAttrCompletion', () => {
});
});
});

describe('constructElementAttrCompletion', () => {
it('returns CompletionEntry for a plain html attribute', () => {
const attrDef: PlainElementAttribute = {
name: 'test',
description: 'test description',
type: 'boolean',
};
expect(constructElementAttrCompletion(attrDef)).toEqual({
insertText: 'test',
kind: 'parameter',
kindModifiers: '',
labelDetails: {
description: '[attr] HTML Element',
detail: ' boolean',
},
name: 'test',
sortText: 'a',
});
});

it('returns CompletionEntry for a custom element attribute', () => {
const attrDef: CustomElementAttribute = {
name: 'test',
description: 'test description',
type: 'string',
deprecated: true,
referenceClass: 'ClassName',
};
expect(constructElementAttrCompletion(attrDef)).toEqual({
insertText: 'test=""',
kind: 'parameter',
kindModifiers: 'deprecated',
labelDetails: {
description: '(deprecated) [attr] ClassName',
detail: ' string',
},
name: 'test',
sortText: 'a',
});
});
});
24 changes: 23 additions & 1 deletion src/plugin/completions/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CompletionEntry, ScriptElementKind } from 'typescript/lib/tsserverlibrary';
import { GlobalAttrType } from '../global-data/global-data.types';
import { CustomElementAttribute } from '../custom-elements/custom-elements.types';
import { GlobalAttrType, PlainElementAttribute } from '../global-data/global-data.types';

export function constructGlobalAriaCompletion(name: string): CompletionEntry {
return {
Expand Down Expand Up @@ -64,3 +65,24 @@ export function constructGlobalAttrCompletion(name: string, type: GlobalAttrType
};
}
}

export function constructElementAttrCompletion(
attr: CustomElementAttribute | PlainElementAttribute
): CompletionEntry {
const { name, type, referenceClass, deprecated } = {
referenceClass: 'HTML Element',
deprecated: false,
...attr,
};
return {
name,
insertText: `${name}${type === 'boolean' ? '' : '=""'}`,
kind: ScriptElementKind.parameterElement,
sortText: 'a',
labelDetails: {
description: (deprecated ? '(deprecated) ' : '') + `[attr] ${referenceClass}`.trim(),
detail: ` ${type}`,
},
kindModifiers: deprecated ? 'deprecated' : '',
};
}
Loading

0 comments on commit 8f652bb

Please sign in to comment.