diff --git a/index.ts b/index.ts index 6317d82..808ec78 100644 --- a/index.ts +++ b/index.ts @@ -14,8 +14,8 @@ import { execute, } from "graphql"; import { useEffect, useState } from "react"; - import { v4 as uuid } from "uuid"; +import { toBase64, fromBase64 } from "js-base64"; export type Record = { type: string; @@ -72,10 +72,14 @@ export type SchemaField = export type Config = { init: Array; - persistence: "none" | "url" | "localStorage"; - localStorageKey: string; + persistence: Persistence | null; }; +interface Persistence { + load(): Array | null; + save(records: Array): void; +} + const FIELD_TYPE_MAP = { Boolean: GraphQLBoolean, String: GraphQLString, @@ -85,8 +89,7 @@ const FIELD_TYPE_MAP = { const DEFAULT_CONFIG: Config = { init: [], - persistence: "none", - localStorageKey: "recordSet", + persistence: null, }; class RecordSet extends EventTarget { @@ -103,29 +106,17 @@ class RecordSet extends EventTarget { ...config, }; - switch (this.config.persistence) { - case "none": { - this.records = this.config.init; - break; - } - case "url": { - this.loadUrl(); - this.addEventListener("change", this.updateUrl); - break; - } - case "localStorage": { - this.loadLocalStorage(); - this.addEventListener("change", this.updateLocalStorage); - break; - } - default: { - throw Error( - `Unknown persistence type "${this.config.persistence}". Where you after "url" or "localStorage"?`, - ); + this.records = this.config.init; + + if (this.config.persistence) { + const loadedRecords = this.config.persistence.load(); + if (loadedRecords !== null) { + this.records = loadedRecords; } + this.addEventListener("change", this.updatePersistence); } - this.generateSchema(schema); + this.schema = this.generateSchema(schema); } public query(query: DocumentNode, variables = {}) { @@ -138,26 +129,14 @@ class RecordSet extends EventTarget { return data; } - private loadUrl() { - // TODO: Load from the URL if there's anything there. - this.records = this.config.init; - } - - private updateUrl() { - // TODO - } - - private loadLocalStorage() { - // TODO: Load from local storage if there's anything there. - this.records = this.config.init; - } - - private updateLocalStorage() { - // TODO + private updatePersistence() { + if (this.config.persistence) { + this.config.persistence.save(this.records); + } } - private generateSchema(schema: Schema) { - const dynamicTypes = {}; + private generateSchema(schema: Schema): GraphQLSchema { + const dynamicTypes: { [key: string]: GraphQLObjectType } = {}; const dynamicQueryFields: GraphQLFieldConfigMap = {}; const dynamicMutationFields: GraphQLFieldConfigMap = {}; @@ -221,7 +200,7 @@ class RecordSet extends EventTarget { result[field] = { type: new GraphQLNonNull(new GraphQLList(NodeType)), resolve: (obj) => - source.reduce((result, { type, field }) => { + source.reduce>((result, { type, field }) => { const inverse = schema[type].fields[field]; if (inverse.type !== "ForeignKey") { throw Error(`Expected ${type}#${field} to be a foreign key.`); @@ -245,7 +224,8 @@ class RecordSet extends EventTarget { } case "oneToOne": { const inverseRecord = resolveInverseOneToOne(this.records, field, obj.id); - return result.find(({ id }) => id === inverseRecord.id) + return inverseRecord === undefined || + result.find(({ id }) => id === inverseRecord.id) ? result : [...result, inverseRecord]; } @@ -255,7 +235,8 @@ class RecordSet extends EventTarget { field, obj.id, ); - return result.find(({ id }) => id === inverseRecord.id) + return inverseRecord === undefined || + result.find(({ id }) => id === inverseRecord.id) ? result : [...result, inverseRecord]; } @@ -393,7 +374,11 @@ class RecordSet extends EventTarget { }, resolve: (_, { id, ...args }) => { const updatedRecord = this.records.find((record) => record.id === id); - for (let arg in args) updatedRecord[arg] = args[arg]; + if (updatedRecord) { + for (let arg in args) { + updatedRecord[arg] = args[arg]; + } + } this.dispatchEvent(new Event("change")); return updatedRecord; }, @@ -502,7 +487,7 @@ class RecordSet extends EventTarget { if (f.cardinality === "oneToOne") { for (let record of this.records) { if (record.type === sourceRecord.type && record[field] === target) { - record[field] = undefined; + delete record[field]; } } } @@ -541,7 +526,7 @@ class RecordSet extends EventTarget { const currentArray = Array.isArray(current) ? current : []; sourceRecord[field] = currentArray.filter((id) => id !== target); } else { - sourceRecord[field] = undefined; + delete sourceRecord[field]; } this.dispatchEvent(new Event("change")); return sourceRecord; @@ -551,13 +536,57 @@ class RecordSet extends EventTarget { }, }); - this.schema = new GraphQLSchema({ + return new GraphQLSchema({ query: QueryType, mutation: MutationType, }); } } +class LocalStoragePersistence implements Persistence { + private key: string; + + constructor(key: string = "recordSet") { + this.key = key; + } + + load(): Array | null { + const value = window.localStorage.getItem(this.key); + return value ? JSON.parse(value) : null; + } + + save(records: Array): void { + window.localStorage.setItem(this.key, JSON.stringify(records)); + } +} + +class UrlPersistence implements Persistence { + load(): Array | null { + let persistedRecords = null; + const hash = window.location.hash.slice(1).trim(); + + if (hash.length > 0) { + try { + persistedRecords = JSON.parse(fromBase64(hash)) as unknown; + } catch (error) { + console.log(error); + } + } + + return persistedRecords && isRecordSet(persistedRecords) ? persistedRecords : []; + } + + save(records: Array): void { + history.replaceState(undefined, "", `#${toBase64(JSON.stringify(records), true)}`); + } +} + +const isRecord = (value: any): value is Record => + typeof value === "object" && typeof value.type === "string" && typeof value.id === "string"; + +const isRecordSet = (value: any): value is Array => + Array.isArray(value) && value.every((record) => isRecord(record)); + const resolveInverseManyToMany = (records: Array, field: string, id: string) => records.filter((record) => { const f = record[field]; @@ -576,11 +605,35 @@ const resolveInverseOneToMany = (records: Array, field: string, id: stri return Array.isArray(x) ? x.includes(id) : false; }); -const createRecordSet = (schema: Schema, config?: Partial) => { - const recordSet = new RecordSet(schema, config); +const createRecordSet = ( + schema: Schema, + config?: Partial<{ + init: Array; + persistence: "url" | "localStorage"; + localStorageKey: string; + }>, +) => { + let persistence = null; + + switch (config?.persistence) { + case "localStorage": { + persistence = new LocalStoragePersistence(config?.localStorageKey); + break; + } + case "url": { + persistence = new UrlPersistence(); + break; + } + } + + const recordSet = new RecordSet(schema, { + init: config?.init, + persistence, + }); + return { recordSet, - useRecordSet: (query, variables = {}) => { + useRecordSet: (query: DocumentNode, variables = {}) => { const [, setTick] = useState(0); useEffect(() => { @@ -644,6 +697,8 @@ const InverseField = ( export { RecordSet, + UrlPersistence, + LocalStoragePersistence, createRecordSet, StringField, NumberField, diff --git a/package.json b/package.json index 6ab4584..97f07e9 100644 --- a/package.json +++ b/package.json @@ -39,16 +39,19 @@ ], "dependencies": { "graphql": "^15.5.1", + "js-base64": "^3.6.1", "uuid": "^8.3.2" }, "peerDependencies": { "react": "^17.0.2" }, "devDependencies": { + "@testing-library/react-hooks": "^7.0.1", "@types/dedent": "^0.7.0", "@types/jest": "^26.0.24", "@types/node": "^16.4.9", - "@types/react": "^17.0.15", + "@types/react": "^17.0.19", + "@types/react-dom": "^17.0.9", "@types/uuid": "^8.3.1", "dedent": "^0.7.0", "flowgen": "^1.14.1", @@ -57,6 +60,7 @@ "np": "^7.5.0", "prettier": "^2.3.2", "react": "^17.0.2", + "react-dom": "^17.0.2", "typescript": "^4.3.5" }, "prettier": { diff --git a/test.ts b/test.ts index 617f226..f6cc1fc 100644 --- a/test.ts +++ b/test.ts @@ -1,6 +1,8 @@ import { printSchema, execute } from "graphql"; import gql from "graphql-tag"; import dedent from "dedent"; +import { toBase64, fromBase64 } from "js-base64"; +import { renderHook, act } from "@testing-library/react-hooks/dom/pure"; import { Schema, RecordSet, @@ -10,6 +12,9 @@ import { DateField, ForeignKeyField, InverseField, + LocalStoragePersistence, + UrlPersistence, + createRecordSet, } from "./index.js"; const MINIMAL_SCHEMA: Schema = { @@ -779,8 +784,6 @@ describe("RecordSet query resolution", () => { type: "Record", id: "71dddd29-680d-4dbd-92ed-2ea085a569cb", manyToMany: [], - manyToOne: undefined, - oneToOne: undefined, oneToMany: [], }, ], @@ -1303,7 +1306,6 @@ describe("RecordSet mutation resolution", () => { { type: "Role", id: "b7290c75-9f4b-4b3c-987d-3ea5b7a707ed", - holder: undefined, responsibilities: [], }, { @@ -1650,6 +1652,8 @@ describe("RecordSet query", () => { }); test("error handling", () => { + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const recordSet = new RecordSet({ Thing: { meta: { @@ -1664,8 +1668,6 @@ describe("RecordSet query", () => { }, }); - console.log = jest.fn(); - expect( recordSet.query(gql` query { @@ -1679,133 +1681,422 @@ describe("RecordSet query", () => { }); expect(console.log).toHaveBeenCalled(); + + consoleLogSpy.mockRestore(); }); }); -describe("helpers", () => { - const recordSet = new RecordSet({ - Role: { - meta: { - singular: "role", - plural: "roles", - }, - fields: { - name: StringField(), - rank: NumberField(), - isArchived: BooleanField(), - dateCreated: DateField(), - lineManager: ForeignKeyField("manyToOne"), - responsibilities: ForeignKeyField("oneToMany"), - holder: ForeignKeyField("oneToOne"), - teams: ForeignKeyField("manyToMany"), - reports: InverseField("Role#lineManager"), +describe("RecordSet persistence", () => { + test("custom", () => { + const persistence = { + load: jest + .fn() + .mockReturnValue([{ type: "Thing", id: "f365bb65-16b2-4053-bbf3-cd207afaa798" }]), + save: jest.fn(), + }; + const recordSet = new RecordSet( + { + Thing: { + meta: { + singular: "thing", + plural: "things", + }, + fields: {}, + }, }, - }, - Responsibility: { - meta: { - singular: "responsibility", - plural: "responsibilities", + { + persistence, }, - fields: { - owner: InverseField("Role#responsibilities"), + ); + recordSet.query(gql` + mutation { + createThing(id: "7cd41d28-810b-4f63-b527-5c376e2571be") { + id + } + } + `); + expect(persistence.load.mock.calls.length).toBe(1); + expect(persistence.save.mock.calls.length).toBe(1); + expect(persistence.save.mock.calls[0][0]).toEqual([ + { type: "Thing", id: "f365bb65-16b2-4053-bbf3-cd207afaa798" }, + { type: "Thing", id: "7cd41d28-810b-4f63-b527-5c376e2571be" }, + ]); + }); + + test("LocalStoragePersistence", () => { + const getItemSpy = jest + .spyOn(global.Storage.prototype, "getItem") + .mockReturnValue( + JSON.stringify([{ type: "Thing", id: "8681019f-6c9e-466c-8867-6844b1b7b3c6" }]), + ); + const setItemSpy = jest.spyOn(global.Storage.prototype, "setItem"); + const persistence = new LocalStoragePersistence(); + const recordSet = new RecordSet( + { + Thing: { + meta: { + singular: "thing", + plural: "things", + }, + fields: {}, + }, }, - }, - Person: { - meta: { - singular: "person", - plural: "people", + { + persistence, }, - fields: { - role: InverseField("Role#holder"), + ); + recordSet.query(gql` + mutation { + createThing(id: "a419c660-dcb6-4ed2-af4b-8a7fef2c0761") { + id + } + } + `); + expect(getItemSpy.mock.calls.length).toBe(1); + expect(getItemSpy.mock.calls[0][0]).toBe("recordSet"); + expect(setItemSpy.mock.calls.length).toBe(1); + expect(setItemSpy.mock.calls[0][0]).toBe("recordSet"); + expect(setItemSpy.mock.calls[0][1]).toBe( + JSON.stringify([ + { type: "Thing", id: "8681019f-6c9e-466c-8867-6844b1b7b3c6" }, + { type: "Thing", id: "a419c660-dcb6-4ed2-af4b-8a7fef2c0761" }, + ]), + ); + getItemSpy.mockRestore(); + setItemSpy.mockRestore(); + }); + + test("UrlPersistence", () => { + history.replaceState( + null, + "", + `#${toBase64( + JSON.stringify([{ type: "Thing", id: "0d07ed2f-20bb-414b-bea1-362a19bebda5" }]), + true, + )}`, + ); + const setReplaceState = jest.spyOn(history, "replaceState"); + const persistence = new UrlPersistence(); + const recordSet = new RecordSet( + { + Thing: { + meta: { + singular: "thing", + plural: "things", + }, + fields: {}, + }, }, - }, - Team: { - meta: { - singular: "team", - plural: "teams", + { + persistence, }, - fields: { - members: InverseField("Role#teams"), + ); + recordSet.query(gql` + mutation { + createThing(id: "0888f6d3-33a2-4818-81e8-311adb5093ae") { + id + } + } + `); + expect(setReplaceState.mock.calls.length).toBe(1); + expect(setReplaceState.mock.calls[0][2]).toBe( + `#${toBase64( + JSON.stringify([ + { type: "Thing", id: "0d07ed2f-20bb-414b-bea1-362a19bebda5" }, + { type: "Thing", id: "0888f6d3-33a2-4818-81e8-311adb5093ae" }, + ]), + true, + )}`, + ); + setReplaceState.mockRestore(); + history.replaceState(null, "", " "); + }); +}); + +describe("schema helpers", () => { + test("StringField", () => { + expect(StringField()).toEqual({ + type: "String", + }); + }); + + test("NumberField", () => { + expect(NumberField()).toEqual({ + type: "Number", + }); + }); + + test("BooleanField", () => { + expect(BooleanField()).toEqual({ + type: "Boolean", + }); + }); + + test("DateField", () => { + expect(DateField()).toEqual({ + type: "Date", + }); + }); + + test("ForeignKeyField", () => { + expect(ForeignKeyField("manyToOne")).toEqual({ + type: "ForeignKey", + cardinality: "manyToOne", + }); + expect(ForeignKeyField("oneToMany")).toEqual({ + type: "ForeignKey", + cardinality: "oneToMany", + }); + expect(ForeignKeyField("oneToOne")).toEqual({ + type: "ForeignKey", + cardinality: "oneToOne", + }); + expect(ForeignKeyField("manyToMany")).toEqual({ + type: "ForeignKey", + cardinality: "manyToMany", + }); + }); + + test("DateField", () => { + expect(InverseField("Role#lineManager")).toEqual({ + type: "Inverse", + source: { + type: "Role", + field: "lineManager", }, - }, + }); + expect(InverseField(["Role#lineManager", "Foo#bar"])).toEqual({ + type: "Inverse", + source: [ + { + type: "Role", + field: "lineManager", + }, + { + type: "Foo", + field: "bar", + }, + ], + }); }); - expect(printSchema(recordSet.schema).trim()).toBe( - dedent` - schema { - query: RootQueryType - mutation: RootMutationType - } - type RootQueryType { - relationships(foreignKeys: [ForeignKeyInput]): [Relationship] - role(id: String!): Role - roles(ids: [String!]): [Role]! - responsibility(id: String!): Responsibility - responsibilities(ids: [String!]): [Responsibility]! - person(id: String!): Person - people(ids: [String!]): [Person]! - team(id: String!): Team - teams(ids: [String!]): [Team]! - } + test("full schema", () => { + const recordSet = new RecordSet({ + Role: { + meta: { + singular: "role", + plural: "roles", + }, + fields: { + name: StringField(), + rank: NumberField(), + isArchived: BooleanField(), + dateCreated: DateField(), + lineManager: ForeignKeyField("manyToOne"), + responsibilities: ForeignKeyField("oneToMany"), + holder: ForeignKeyField("oneToOne"), + teams: ForeignKeyField("manyToMany"), + reports: InverseField("Role#lineManager"), + }, + }, + Responsibility: { + meta: { + singular: "responsibility", + plural: "responsibilities", + }, + fields: { + owner: InverseField("Role#responsibilities"), + }, + }, + Person: { + meta: { + singular: "person", + plural: "people", + }, + fields: { + role: InverseField("Role#holder"), + }, + }, + Team: { + meta: { + singular: "team", + plural: "teams", + }, + fields: { + members: InverseField("Role#teams"), + }, + }, + }); + expect(printSchema(recordSet.schema).trim()).toBe( + dedent` + schema { + query: RootQueryType + mutation: RootMutationType + } - type Relationship { - source: Node - target: Node - } + type RootQueryType { + relationships(foreignKeys: [ForeignKeyInput]): [Relationship] + role(id: String!): Role + roles(ids: [String!]): [Role]! + responsibility(id: String!): Responsibility + responsibilities(ids: [String!]): [Responsibility]! + person(id: String!): Person + people(ids: [String!]): [Person]! + team(id: String!): Team + teams(ids: [String!]): [Team]! + } - interface Node { - id: String! - } + type Relationship { + source: Node + target: Node + } - input ForeignKeyInput { - type: String! - field: String! - } + interface Node { + id: String! + } - type Role implements Node { - id: String! - name: String - rank: Float - isArchived: Boolean - dateCreated: String - lineManager: Node - responsibilities: [Node]! - holder: Node - teams: [Node]! - reports: [Node]! - } + input ForeignKeyInput { + type: String! + field: String! + } - type Responsibility implements Node { - id: String! - owner: Node - } + type Role implements Node { + id: String! + name: String + rank: Float + isArchived: Boolean + dateCreated: String + lineManager: Node + responsibilities: [Node]! + holder: Node + teams: [Node]! + reports: [Node]! + } - type Person implements Node { - id: String! - role: Node - } + type Responsibility implements Node { + id: String! + owner: Node + } - type Team implements Node { - id: String! - members: [Node]! - } + type Person implements Node { + id: String! + role: Node + } - type RootMutationType { - addRelationship(field: String!, source: String!, target: String!): Node - removeRelationship(field: String!, source: String!, target: String!): Node - createRole(id: String, name: String, rank: Float, isArchived: Boolean, dateCreated: String): Role - updateRole(id: String!, name: String, rank: Float, isArchived: Boolean, dateCreated: String): Role - deleteRole(id: String!): Role - createResponsibility(id: String): Responsibility - updateResponsibility(id: String!): Responsibility - deleteResponsibility(id: String!): Responsibility - createPerson(id: String): Person - updatePerson(id: String!): Person - deletePerson(id: String!): Person - createTeam(id: String): Team - updateTeam(id: String!): Team - deleteTeam(id: String!): Team - } - `.trim(), - ); + type Team implements Node { + id: String! + members: [Node]! + } + + type RootMutationType { + addRelationship(field: String!, source: String!, target: String!): Node + removeRelationship(field: String!, source: String!, target: String!): Node + createRole(id: String, name: String, rank: Float, isArchived: Boolean, dateCreated: String): Role + updateRole(id: String!, name: String, rank: Float, isArchived: Boolean, dateCreated: String): Role + deleteRole(id: String!): Role + createResponsibility(id: String): Responsibility + updateResponsibility(id: String!): Responsibility + deleteResponsibility(id: String!): Responsibility + createPerson(id: String): Person + updatePerson(id: String!): Person + deletePerson(id: String!): Person + createTeam(id: String): Team + updateTeam(id: String!): Team + deleteTeam(id: String!): Team + } + `.trim(), + ); + }); +}); + +describe("useRecordSet", () => { + test("end to end", () => { + const { useRecordSet, updateRecordSet } = createRecordSet( + { + Role: { + meta: { + singular: "role", + plural: "roles", + }, + fields: { + name: StringField(), + rank: NumberField(), + isArchived: BooleanField(), + dateCreated: DateField(), + lineManager: ForeignKeyField("manyToOne"), + responsibilities: ForeignKeyField("oneToMany"), + holder: ForeignKeyField("oneToOne"), + teams: ForeignKeyField("manyToMany"), + reports: InverseField("Role#lineManager"), + }, + }, + Responsibility: { + meta: { + singular: "responsibility", + plural: "responsibilities", + }, + fields: { + owner: InverseField("Role#responsibilities"), + }, + }, + Person: { + meta: { + singular: "person", + plural: "people", + }, + fields: { + role: InverseField("Role#holder"), + }, + }, + Team: { + meta: { + singular: "team", + plural: "teams", + }, + fields: { + members: InverseField("Role#teams"), + }, + }, + }, + { + persistence: "url", + }, + ); + const { result } = renderHook(() => + useRecordSet(gql` + query { + roles { + id + name + rank + } + } + `), + ); + + act(() => { + updateRecordSet(gql` + mutation { + createRole(id: "de483855-adcb-4fad-9825-69bc72d3c35c", name: "Example", rank: 1) + } + `); + }); + + expect(result.current?.roles).toEqual([ + { + id: "de483855-adcb-4fad-9825-69bc72d3c35c", + name: "Example", + rank: 1, + }, + ]); + + expect(JSON.parse(fromBase64(window.location.hash.slice(1).trim()))).toEqual([ + { + type: "Role", + id: "de483855-adcb-4fad-9825-69bc72d3c35c", + name: "Example", + rank: 1, + }, + ]); + }); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3e4bff1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "strict": true + } +} diff --git a/yarn.lock b/yarn.lock index 544e3d6..e2bbcdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -270,6 +270,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" +"@babel/runtime@^7.12.5": + version "7.15.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b" + integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.14.5", "@babel/template@^7.3.3": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" @@ -574,6 +581,17 @@ dependencies: defer-to-connect "^2.0.0" +"@testing-library/react-hooks@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.1.tgz#8429d8bf55bfe82e486bd582dd06457c2464900a" + integrity sha512-bpEQ2SHSBSzBmfJ437NmnP+oArQ7aVmmULiAp6Ag2rtyLBLPNFSMmgltUbFGmQOJdPWo4Ub31kpUC5T46zXNwQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + react-error-boundary "^3.1.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -703,10 +721,24 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== -"@types/react@^17.0.15": - version "17.0.15" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.15.tgz#c7533dc38025677e312606502df7656a6ea626d0" - integrity sha512-uTKHDK9STXFHLaKv6IMnwp52fm0hwU+N89w/p9grdUqcFA6WuqDyPhaWopbNyE1k/VhgzmHl8pu1L4wITtmlLw== +"@types/react-dom@>=16.9.0", "@types/react-dom@^17.0.9": + version "17.0.9" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" + integrity sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg== + dependencies: + "@types/react" "*" + +"@types/react-test-renderer@>=16.9.0": + version "17.0.1" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b" + integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^17.0.19": + version "17.0.19" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.19.tgz#8f2a85e8180a43b57966b237d26a29481dacc991" + integrity sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -2761,6 +2793,11 @@ jest@^27.0.6: import-local "^3.0.2" jest-cli "^27.0.6" +js-base64@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.6.1.tgz#555aae398b74694b4037af1f8a5a6209d170efbe" + integrity sha512-Frdq2+tRRGLQUIQOgsIGSCd1VePCS2fsddTG5dTCqR0JHgltXWfsxnY0gIXPoMeRmdom6Oyq+UMOFg5suduOjQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3648,6 +3685,22 @@ rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-dom@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + +react-error-boundary@^3.1.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b" + integrity sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA== + dependencies: + "@babel/runtime" "^7.12.5" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -3695,6 +3748,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +regenerator-runtime@^0.13.4: + version "0.13.9" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" + integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== + registry-auth-token@^4.0.0: version "4.2.1" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" @@ -3817,6 +3875,14 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scoped-regex@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f"