From c88da6fa6dea3962adb5bdbaba376ff8f89a1644 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 27 May 2022 13:32:27 +0100 Subject: [PATCH] Add types (#8154) GitOrigin-RevId: 41ee6b6873a01fbfedc41a884b9e3ebee47fc08f --- package-lock.json | 87 +++++++++++++++---- services/web/.eslintrc | 4 +- .../js/features/dictionary/ignored-words.ts | 8 +- .../js/shared/hooks/use-persisted-state.ts | 2 +- .../hooks/use-scope-value-setter-only.ts | 6 +- .../js/shared/hooks/use-scope-value.ts | 2 +- .../frontend/stories/hooks/use-fetch-mock.tsx | 4 +- .../web/frontend/stories/hooks/use-scope.tsx | 2 +- services/web/package.json | 3 +- .../pdf-preview/pdf-logs-entries.spec.tsx | 2 +- .../pdf-preview/pdf-preview.spec.tsx | 4 +- .../pdf-preview/pdf-synctex-controls.spec.tsx | 26 ++++-- .../shared/hooks/use-persisted-state.test.tsx | 2 +- services/web/types/annotation.ts | 5 ++ services/web/types/change.ts | 33 +++++++ services/web/types/current-doc.ts | 32 +++++++ .../web/types/{fileref.ts => file-ref.ts} | 0 services/web/types/folder.ts | 2 +- services/web/types/highlight.ts | 5 ++ 19 files changed, 188 insertions(+), 41 deletions(-) create mode 100644 services/web/types/annotation.ts create mode 100644 services/web/types/change.ts create mode 100644 services/web/types/current-doc.ts rename services/web/types/{fileref.ts => file-ref.ts} (100%) create mode 100644 services/web/types/highlight.ts diff --git a/package-lock.json b/package-lock.json index 567fd3cc05..9a8c1bf895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "overleaf", + "hasInstallScript": true, "workspaces": [ "libraries/*", "services/analytics", @@ -5923,6 +5924,12 @@ "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.4.tgz", "integrity": "sha512-uO4CD2ELOjw8tasUrAhvnn2W4A0ZECOvMjCivJr4gA9pGgjv+qxKWY9GLTMVEK8ej85BxQOocUyE7hImmSQYcg==" }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "node_modules/@types/ws": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", @@ -35046,7 +35053,7 @@ "jsonwebtoken": "^8.5.1", "ldapjs": "^0.7.1", "lodash": "^4.17.19", - "lru-cache": "^6.0.0", + "lru-cache": "^7.10.1", "mailchimp-api-v3": "^1.12.0", "marked": "^0.3.5", "match-sorter": "^6.2.0", @@ -35133,6 +35140,7 @@ "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", "@types/sinon-chai": "^3.2.8", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/parser": "^5.3.1", "acorn": "^7.1.1", @@ -35571,6 +35579,18 @@ "webpack": "^5.0.0" } }, + "services/web/node_modules/css-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "services/web/node_modules/css-loader/node_modules/semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -36088,14 +36108,11 @@ "dev": true }, "services/web/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz", + "integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==", "engines": { - "node": ">=10" + "node": ">=12" } }, "services/web/node_modules/marked": { @@ -36522,6 +36539,18 @@ "webpack": "^5.0.0" } }, + "services/web/node_modules/postcss-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "services/web/node_modules/postcss-loader/node_modules/semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -37237,7 +37266,8 @@ "services/web/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true } }, "dependencies": { @@ -42714,6 +42744,7 @@ "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", "@types/sinon-chai": "^3.2.8", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/parser": "^5.3.1", "@uppy/core": "^1.15.0", @@ -42818,7 +42849,7 @@ "less-loader": "^10.2.0", "less-plugin-autoprefix": "^2.0.0", "lodash": "^4.17.19", - "lru-cache": "^6.0.0", + "lru-cache": "^7.10.1", "mailchimp-api-v3": "^1.12.0", "marked": "^0.3.5", "match-sorter": "^6.2.0", @@ -43194,6 +43225,15 @@ "semver": "^7.3.5" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -43567,12 +43607,9 @@ "dev": true }, "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz", + "integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==" }, "marked": { "version": "0.3.19", @@ -43855,6 +43892,15 @@ "semver": "^7.3.5" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -44345,7 +44391,8 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true } } }, @@ -45528,6 +45575,12 @@ "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.4.tgz", "integrity": "sha512-uO4CD2ELOjw8tasUrAhvnn2W4A0ZECOvMjCivJr4gA9pGgjv+qxKWY9GLTMVEK8ej85BxQOocUyE7hImmSQYcg==" }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "@types/ws": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", diff --git a/services/web/.eslintrc b/services/web/.eslintrc index 9306ef3d42..fa9613ce20 100644 --- a/services/web/.eslintrc +++ b/services/web/.eslintrc @@ -25,7 +25,9 @@ "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-this-alias": "off" + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/ban-ts-comment": "off" }, "overrides": [ // NOTE: changing paths may require updating them in the Makefile too. diff --git a/services/web/frontend/js/features/dictionary/ignored-words.ts b/services/web/frontend/js/features/dictionary/ignored-words.ts index 0d7156069d..b720822a9d 100644 --- a/services/web/frontend/js/features/dictionary/ignored-words.ts +++ b/services/web/frontend/js/features/dictionary/ignored-words.ts @@ -2,7 +2,7 @@ import getMeta from '../../utils/meta' import { IGNORED_MISSPELLINGS } from '../../ide/editor/directives/aceEditor/spell-check/IgnoredMisspellings' export class IgnoredWords { - public learnedWords: Set + public learnedWords!: Set private ignoredMisspellings: Set constructor() { @@ -16,21 +16,21 @@ export class IgnoredWords { window.dispatchEvent(new CustomEvent('learnedWords:reset')) } - add(wordText) { + add(wordText: string) { this.learnedWords.add(wordText) window.dispatchEvent( new CustomEvent('learnedWords:add', { detail: wordText }) ) } - remove(wordText) { + remove(wordText: string) { this.learnedWords.delete(wordText) window.dispatchEvent( new CustomEvent('learnedWords:remove', { detail: wordText }) ) } - has(wordText) { + has(wordText: string) { return ( this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText) ) diff --git a/services/web/frontend/js/shared/hooks/use-persisted-state.ts b/services/web/frontend/js/shared/hooks/use-persisted-state.ts index cc4e4491f8..bcd9888a9f 100644 --- a/services/web/frontend/js/shared/hooks/use-persisted-state.ts +++ b/services/web/frontend/js/shared/hooks/use-persisted-state.ts @@ -8,7 +8,7 @@ import { import _ from 'lodash' import localStorage from '../../infrastructure/local-storage' -function usePersistedState( +function usePersistedState( key: string, defaultValue?: T, listen = false diff --git a/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts b/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts index 832c89f050..402d47165c 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts +++ b/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts @@ -18,13 +18,13 @@ import _ from 'lodash' export default function useScopeValueSetterOnly( path: string, // dot '.' path of a property in the Angular scope. defaultValue?: T -): [T, Dispatch>] { +): [T | undefined, Dispatch>] { const { $scope } = useIdeContext() - const [value, setValue] = useState(defaultValue) + const [value, setValue] = useState(defaultValue) const scopeSetter = useCallback( - (newValue: SetStateAction) => { + (newValue: SetStateAction) => { setValue(val => { const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue $scope.$applyAsync(() => _.set($scope, path, actualNewValue)) diff --git a/services/web/frontend/js/shared/hooks/use-scope-value.ts b/services/web/frontend/js/shared/hooks/use-scope-value.ts index 7c608e1172..e3f3a50b75 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-value.ts +++ b/services/web/frontend/js/shared/hooks/use-scope-value.ts @@ -24,7 +24,7 @@ export default function useScopeValue( useEffect(() => { return $scope.$watch( path, - newValue => { + (newValue: T) => { setValue(() => { // NOTE: this is deliberately wrapped in a function, // to avoid calling setValue directly with a value that's a function diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.tsx b/services/web/frontend/stories/hooks/use-fetch-mock.tsx index dd2fcd68c3..c5a527c81d 100644 --- a/services/web/frontend/stories/hooks/use-fetch-mock.tsx +++ b/services/web/frontend/stories/hooks/use-fetch-mock.tsx @@ -5,7 +5,9 @@ fetchMock.config.fallbackToNetwork = true /** * Run callback to mock fetch routes, call restore() when unmounted */ -export default function useFetchMock(callback) { +export default function useFetchMock( + callback: (value: typeof fetchMock) => void +) { useLayoutEffect(() => { callback(fetchMock) diff --git a/services/web/frontend/stories/hooks/use-scope.tsx b/services/web/frontend/stories/hooks/use-scope.tsx index 7c609fbc55..5add9c0f90 100644 --- a/services/web/frontend/stories/hooks/use-scope.tsx +++ b/services/web/frontend/stories/hooks/use-scope.tsx @@ -5,7 +5,7 @@ import { useLayoutEffect, useRef } from 'react' * Merge properties with the scope object, for use in Storybook stories */ export const useScope = (scope: Record) => { - const scopeRef = useRef(null) + const scopeRef = useRef | null>(null) if (scopeRef.current === null) { scopeRef.current = scope } diff --git a/services/web/package.json b/services/web/package.json index b8b549e11f..781c3ae50c 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -131,7 +131,7 @@ "jsonwebtoken": "^8.5.1", "ldapjs": "^0.7.1", "lodash": "^4.17.19", - "lru-cache": "^6.0.0", + "lru-cache": "^7.10.1", "mailchimp-api-v3": "^1.12.0", "marked": "^0.3.5", "match-sorter": "^6.2.0", @@ -218,6 +218,7 @@ "@types/react-bootstrap": "^0.32.29", "@types/react-dom": "^17.0.13", "@types/sinon-chai": "^3.2.8", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/parser": "^5.3.1", "acorn": "^7.1.1", diff --git a/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx index c07e2881c1..97c6e1cb7b 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx @@ -21,7 +21,7 @@ describe('', function () { }, ] - let props + let props: Record beforeEach(function () { props = { diff --git a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx index 8f20d0838d..a340e60e4b 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx @@ -4,7 +4,7 @@ import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/ import { EditorProviders } from '../../helpers/editor-providers' import { mockScope } from './scope' -const storeAndFireEvent = (win, key, value) => { +const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => { localStorage.setItem(key, value) win.dispatchEvent(new StorageEvent('storage', { key })) } @@ -98,7 +98,7 @@ describe('', function () { }) it('does not compile while compiling', function () { - let compileResolve + let compileResolve: (value?: unknown) => void let counter = 0 const promise = new Promise(resolve => { diff --git a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx index 42c4676941..21971696f5 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx @@ -25,15 +25,25 @@ const mockHighlights = [ }, ] -const mockPosition = { +type Position = { + page: number + offset: { top: number; left: number } + pageSize: { height: number; width: number } +} + +const mockPosition: Position = { page: 1, offset: { top: 10, left: 10 }, pageSize: { height: 500, width: 500 }, } -const mockSelectedEntities = [{ type: 'doc' }] +type Entity = { + type: string +} -const WithPosition = ({ mockPosition }) => { +const mockSelectedEntities: Entity[] = [{ type: 'doc' }] + +const WithPosition = ({ mockPosition }: { mockPosition: Position }) => { const { setPosition } = useCompileContext() // mock PDF scroll position update @@ -44,7 +54,11 @@ const WithPosition = ({ mockPosition }) => { return null } -const WithSelectedEntities = ({ mockSelectedEntities = [] }) => { +const WithSelectedEntities = ({ + mockSelectedEntities = [], +}: { + mockSelectedEntities: Entity[] +}) => { const { setSelectedEntities } = useFileTreeData() useEffect(() => { @@ -210,10 +224,10 @@ describe('', function () { cy.wait('@sync-code').should(() => { const messages = sysendTestHelper .getAllBroacastMessages() - .map(item => item.args[1]) + .map((item: any) => item.args[1]) const message = messages.find( - message => message.event === 'action-setHighlights' + (message: any) => message.event === 'action-setHighlights' ) // synctex is called locally and the result are broadcast for the detached tab diff --git a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx index 32e3dd3bd5..90dc4cde07 100644 --- a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx +++ b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx @@ -119,7 +119,7 @@ describe('usePersistedState', function () { expect(window.Storage.prototype.setItem).to.have.callCount(1) const Test = () => { - const [value, setValue] = usePersistedState(key) + const [value, setValue] = usePersistedState(key) useEffect(() => { setValue(value => value + 'bar') diff --git a/services/web/types/annotation.ts b/services/web/types/annotation.ts new file mode 100644 index 0000000000..2b08c2ff74 --- /dev/null +++ b/services/web/types/annotation.ts @@ -0,0 +1,5 @@ +export type Annotation = { + row: number + type: 'info' | 'warning' | 'error' + text: string +} diff --git a/services/web/types/change.ts b/services/web/types/change.ts new file mode 100644 index 0000000000..65b3c1ccf8 --- /dev/null +++ b/services/web/types/change.ts @@ -0,0 +1,33 @@ +export interface Operation { + p: number +} + +export interface InsertOperation extends Operation { + i: string + t: string +} + +export interface ChangeOperation extends Operation { + c: string + t: string +} + +export interface DeleteOperation extends Operation { + d: string +} + +export interface CommentOperation extends Operation { + c: string +} + +export type AnyOperation = + | InsertOperation + | ChangeOperation + | DeleteOperation + | CommentOperation + +export type Change = { + id: string + metadata?: string + op: T +} diff --git a/services/web/types/current-doc.ts b/services/web/types/current-doc.ts new file mode 100644 index 0000000000..6f1ff6ebea --- /dev/null +++ b/services/web/types/current-doc.ts @@ -0,0 +1,32 @@ +import { EditorFacade } from '../modules/source-editor/frontend/js/extensions/realtime' +import { + AnyOperation, + Change, + ChangeOperation, + CommentOperation, + DeleteOperation, + InsertOperation, +} from './change' + +export type CurrentDoc = { + doc_id: string + docName: string + track_changes_as: string | null + ranges: { + changes: Change[] + comments: Change[] + resolvedThreadIds: Record + removeCommentId: (id: string) => void + removeChangeIds: (ids: string[]) => void + getChanges: ( + ids: string[] + ) => Change[] + validate: (text: string) => void + } + attachToCM6: (editor: EditorFacade) => void + detachFromCM6: () => void + on: (eventName: string, listener: EventListener) => void + off: (eventName: string) => void + submitOp: (op: AnyOperation) => void + getSnapshot: () => string +} diff --git a/services/web/types/fileref.ts b/services/web/types/file-ref.ts similarity index 100% rename from services/web/types/fileref.ts rename to services/web/types/file-ref.ts diff --git a/services/web/types/folder.ts b/services/web/types/folder.ts index 34c64c35aa..389de25242 100644 --- a/services/web/types/folder.ts +++ b/services/web/types/folder.ts @@ -1,5 +1,5 @@ import { Doc } from './doc' -import { FileRef } from './fileref' +import { FileRef } from './file-ref' export type Folder = { _id: string diff --git a/services/web/types/highlight.ts b/services/web/types/highlight.ts new file mode 100644 index 0000000000..4017158ca7 --- /dev/null +++ b/services/web/types/highlight.ts @@ -0,0 +1,5 @@ +export type Highlight = { + cursor: { row: number; column: number } + hue: string + label: string +}