diff --git a/oeq-ts-rest-api/package-lock.json b/oeq-ts-rest-api/package-lock.json index bb74c51d8b..6d19c4d9e9 100644 --- a/oeq-ts-rest-api/package-lock.json +++ b/oeq-ts-rest-api/package-lock.json @@ -18,7 +18,6 @@ "monocle-ts": "2.3.13", "newtype-ts": "0.3.5", "query-string": "^7.0.0", - "runtypes": "6.7.0", "tough-cookie": "^4.0.0" }, "devDependencies": { @@ -4844,11 +4843,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/runtypes": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/runtypes/-/runtypes-6.7.0.tgz", - "integrity": "sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==" - }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -9031,11 +9025,6 @@ "queue-microtask": "^1.2.2" } }, - "runtypes": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/runtypes/-/runtypes-6.7.0.tgz", - "integrity": "sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==" - }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", diff --git a/oeq-ts-rest-api/package.json b/oeq-ts-rest-api/package.json index e90a37dc45..889916dae8 100644 --- a/oeq-ts-rest-api/package.json +++ b/oeq-ts-rest-api/package.json @@ -38,7 +38,6 @@ "monocle-ts": "2.3.13", "newtype-ts": "0.3.5", "query-string": "^7.0.0", - "runtypes": "6.7.0", "tough-cookie": "^4.0.0" }, "devDependencies": { diff --git a/oeq-ts-rest-api/src/Common.ts b/oeq-ts-rest-api/src/Common.ts index bd177cf7ab..110bde56fc 100644 --- a/oeq-ts-rest-api/src/Common.ts +++ b/oeq-ts-rest-api/src/Common.ts @@ -17,7 +17,6 @@ */ import { pipe } from 'fp-ts/function'; import * as t from 'io-ts'; -import { Literal, Union } from 'runtypes'; import { BaseEntityCodec, BaseEntitySummaryCodec, @@ -109,19 +108,6 @@ export const isBaseEntitySummaryArray = ( ): instance is BaseEntitySummary[] => pipe(instance, validate(t.array(BaseEntitySummaryCodec))); -export const ItemStatuses = Union( - Literal('ARCHIVED'), - Literal('DELETED'), - Literal('DRAFT'), - Literal('LIVE'), - Literal('MODERATING'), - Literal('PERSONAL'), - Literal('REJECTED'), - Literal('REVIEW'), - Literal('SUSPENDED') -); - -// todo: fix this type alias which is not in sync with the runtype. Jira ticket: OEQ-1438 export type ItemStatus = | 'ARCHIVED' | 'DELETED' diff --git a/oeq-ts-rest-api/src/Search.ts b/oeq-ts-rest-api/src/Search.ts index d5226f831c..e9af1461e2 100644 --- a/oeq-ts-rest-api/src/Search.ts +++ b/oeq-ts-rest-api/src/Search.ts @@ -16,7 +16,6 @@ * limitations under the License. */ import { stringify } from 'query-string'; -import { Literal, Union } from 'runtypes'; import { GET, HEAD, POST } from './AxiosInstance'; import type { i18nString, ItemStatus } from './Common'; import { SearchResultCodec, SearchResultItemRawCodec } from './gen/Search'; @@ -28,17 +27,6 @@ import { convertDateFields, validate } from './Utils'; */ export type Must = [string, string[]]; -export const SortOrderRunTypes = Union( - Literal('rank'), - Literal('datemodified'), - Literal('datecreated'), - Literal('name'), - Literal('rating'), - Literal('task_lastaction'), - Literal('task_submitted') -); - -// todo: fix this type alias which is not in sync with the runtype. Jira ticket: OEQ-1438 export type SortOrder = | 'rank' | 'datemodified' diff --git a/oeq-ts-rest-api/src/SearchSettings.ts b/oeq-ts-rest-api/src/SearchSettings.ts index 768e4095ac..403fe520a7 100644 --- a/oeq-ts-rest-api/src/SearchSettings.ts +++ b/oeq-ts-rest-api/src/SearchSettings.ts @@ -15,19 +15,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Literal, Union } from 'runtypes'; import { GET, PUT } from './AxiosInstance'; import { SettingsCodec } from './gen/SearchSettings'; import type { SortOrder } from './Search'; import { validate } from './Utils'; -export const ContentIndexLevelRunTypes = Union( - Literal(0), - Literal(1), - Literal(2) -); - -// todo: fix this type alias which is not in sync with the runtype. Jira ticket: OEQ-1438 export type ContentIndexLevel = 0 | 1 | 2; export interface Settings { diff --git a/oeq-ts-rest-api/src/WizardControl.ts b/oeq-ts-rest-api/src/WizardControl.ts index 14e6a9f96b..44c5e8071f 100644 --- a/oeq-ts-rest-api/src/WizardControl.ts +++ b/oeq-ts-rest-api/src/WizardControl.ts @@ -15,7 +15,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Literal, Union } from 'runtypes'; import type { UuidString } from './Common'; import type { SelectionRestriction, TermStorageFormat } from './Taxonomy'; import type { @@ -24,23 +23,6 @@ import type { WizardDateFormat, } from './WizardCommonTypes'; -/** - * Runtypes definition for Wizard control type. - */ -export const RuntypesControlType = Union( - Literal('calendar'), - Literal('checkboxgroup'), - Literal('editbox'), - Literal('html'), - Literal('listbox'), - Literal('radiogroup'), - Literal('shufflebox'), - Literal('shufflelist'), - Literal('termselector'), - Literal('userselector') -); - -// todo: fix this type alias which is not in sync with the runtype. Jira ticket: OEQ-1438 /** * Supported Wizard Control types. */ diff --git a/oeq-ts-rest-api/src/index.ts b/oeq-ts-rest-api/src/index.ts index 8e8effc0f2..5b542a4890 100644 --- a/oeq-ts-rest-api/src/index.ts +++ b/oeq-ts-rest-api/src/index.ts @@ -15,6 +15,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as CommonCodec from './gen/Common'; +import * as SearchCodec from './gen/Search'; +import * as WizardControlCodec from './gen/WizardControl'; + export * as Acl from './Acl'; export * as AdvancedSearch from './AdvancedSearch'; export * as Auth from './Auth'; @@ -44,3 +48,9 @@ export * as UserQuery from './UserQuery'; export * as Utils from './Utils'; export * as WizardCommonTypes from './WizardCommonTypes'; export * as WizardControl from './WizardControl'; + +export const Codec = { + Common: CommonCodec, + Search: SearchCodec, + WizardControl: WizardControlCodec, +}; diff --git a/react-front-end/__mocks__/ACLRecipientModule.mock.ts b/react-front-end/__mocks__/ACLRecipientModule.mock.ts index 556c48abea..e5a610d6a4 100644 --- a/react-front-end/__mocks__/ACLRecipientModule.mock.ts +++ b/react-front-end/__mocks__/ACLRecipientModule.mock.ts @@ -21,7 +21,7 @@ import { LOGGED_IN_USER_ROLE_ID, } from "../tsrc/modules/ACLRecipientModule"; -export const ownerRecipient = { +export const ownerRecipient: ACLRecipient = { expression: "$OWNER", type: "$OWNER", }; @@ -32,7 +32,7 @@ export const ownerRecipientWithName: ACLRecipient = { name: ownerRecipientHumanReadableExpression, }; -export const everyoneRecipient = { +export const everyoneRecipient: ACLRecipient = { expression: "*", type: "*", }; @@ -43,7 +43,7 @@ export const everyoneRecipientWithName: ACLRecipient = { name: everyoneRecipientHumanReadableExpression, }; -export const user100Recipient = { +export const user100Recipient: ACLRecipient = { expression: "20483af2-fe56-4499-a54b-8d7452156895", type: "U", }; @@ -56,16 +56,16 @@ export const user100RecipientWithName = { name: user100RecipientHumanReadableExpression, }; -export const user200Recipient = { +export const user200Recipient: ACLRecipient = { expression: "f9ec8b09-cf64-44ff-8a0a-08a8f2f9272a", type: "U", }; -export const user200RecipientWithName = { +export const user200RecipientWithName: ACLRecipient = { ...user200Recipient, name: "Racheal Carlyle [user200]", }; -export const user300Recipient = { +export const user300Recipient: ACLRecipient = { expression: "eb75a832-6533-4d72-93f4-2b7a1b108951", type: "U", }; @@ -74,26 +74,26 @@ export const user300RecipientWithName = { name: "Yasmin Day [user300]", }; -export const user400Recipient = { +export const user400Recipient: ACLRecipient = { expression: "1c2ff1d0-9040-4985-a450-0ff6422ba5ef", type: "U", }; -export const user400RecipientWithName = { +export const user400RecipientWithName: ACLRecipient = { ...user400Recipient, name: "Ronny Southgate [user400]", }; -export const userAdminRecipient = { +export const userAdminRecipient: ACLRecipient = { expression: "75abbd62-d91c-4ce5-b4b5-339e0d44ac0e", type: "U", }; -export const userContentAdminRecipient = { +export const userContentAdminRecipient: ACLRecipient = { expression: "2", type: "U", }; -export const roleGuestRecipient = { +export const roleGuestRecipient: ACLRecipient = { expression: GUEST_USER_ROLE_ID, type: "R", }; @@ -105,14 +105,14 @@ export const roleGuestRecipientWithName = { }; export const LOGGED_IN_USER_ROLE_NAME = "Logged In User Role"; -export const roleLoggedRecipientWithName = { +export const roleLoggedRecipientWithName: ACLRecipient = { expression: LOGGED_IN_USER_ROLE_ID, type: "R", name: LOGGED_IN_USER_ROLE_NAME, }; export const roleLoggedRecipientRawExpression = `R:${LOGGED_IN_USER_ROLE_ID}`; -export const role100RecipientWithName = { +export const role100RecipientWithName: ACLRecipient = { expression: "fda99983-9eda-440a-ac68-0f746173fdcb", type: "R", name: "role100", @@ -124,7 +124,7 @@ export const role200RecipientWithName = { name: "role200", }; -export const groupStudentRecipient = { +export const groupStudentRecipient: ACLRecipient = { expression: "99806ac8-410e-4c60-b3ab-22575276f0f0", type: "G", }; @@ -133,36 +133,36 @@ export const groupStudentRecipientRawExpression = export const groupStudentRecipientHumanReadableExpression = "Engineering & Computer Science Students"; -export const groupStaffRecipient = { +export const groupStaffRecipient: ACLRecipient = { expression: "d0265a33-8f89-4cea-8a36-45fd3c4cf5a1", type: "G", }; -export const group100RecipientWithName = { +export const group100RecipientWithName: ACLRecipient = { expression: "303e758c-0051-4aea-9a8e-421f93ed9d1a", type: "G", name: "group100", }; -export const group200RecipientWithName = { +export const group200RecipientWithName: ACLRecipient = { expression: "d7dd1907-5731-4244-9a65-e0e847f68604", type: "G", name: "group200", }; -export const group300RecipientWithName = { +export const group300RecipientWithName: ACLRecipient = { expression: "f921a6e3-69a6-4ec4-8cf8-bc193beda5f6", type: "G", name: "group300", }; -export const group400RecipientWithName = { +export const group400RecipientWithName: ACLRecipient = { expression: "a2576dea-bd5c-490b-a065-637068e1a4fb", type: "G", name: "group400", }; -export const ssoMoodleRecipient = { +export const ssoMoodleRecipient: ACLRecipient = { expression: "moodle", type: "T", }; @@ -174,26 +174,26 @@ export const ssoMoodleRecipientWithName = { }; // helper function to generate an IP recipient -export const ipRecipient = (ip: string) => ({ +export const ipRecipient = (ip: string): ACLRecipient => ({ expression: ip, type: "I", }); // helper function to generate an IP recipient with name -export const ipRecipientWithName = (ip: string) => ({ +export const ipRecipientWithName = (ip: string): ACLRecipient => ({ expression: ip, type: "I", name: `From ${ip}`, }); // helper function to generate an refer recipient -export const referRecipient = (refer: string) => ({ +export const referRecipient = (refer: string): ACLRecipient => ({ expression: refer, type: "F", }); // helper function to generate a referrer recipient with name -export const referRecipientWithName = (refer: string) => ({ +export const referRecipientWithName = (refer: string): ACLRecipient => ({ ...referRecipient(refer), name: `From ${decodeURIComponent(refer)}`, }); diff --git a/react-front-end/__tests__/tsrc/components/aclexpressionbuilder/ACLExpressionBuilder.test.tsx b/react-front-end/__tests__/tsrc/components/aclexpressionbuilder/ACLExpressionBuilder.test.tsx index a9dd4f5dd1..1505089616 100644 --- a/react-front-end/__tests__/tsrc/components/aclexpressionbuilder/ACLExpressionBuilder.test.tsx +++ b/react-front-end/__tests__/tsrc/components/aclexpressionbuilder/ACLExpressionBuilder.test.tsx @@ -114,7 +114,7 @@ describe("", () => { // delete recipient await clickDeleteButtonForRecipient( container, - user200RecipientWithName.name + user200RecipientWithName.name! ); // click ok button to see if the result is what we want await userEvent.click(getByText(okLabel)); @@ -247,14 +247,14 @@ describe("", () => { [ "group", groupsRadioLabel, - group100RecipientWithName.name, + group100RecipientWithName.name!, searchGroup, "G:303e758c-0051-4aea-9a8e-421f93ed9d1a", ], [ "role", rolesRadioLabel, - role100RecipientWithName.name, + role100RecipientWithName.name!, searchRole, "R:fda99983-9eda-440a-ac68-0f746173fdcb", ], @@ -446,7 +446,7 @@ describe("", () => { // click add button await userEvent.click(getByText(addLabel)); // wait for adding action - await findByText(ipRecipient.name); + await findByText(ipRecipient.name!); // click ok button to check the result await userEvent.click(getByText(okLabel)); @@ -486,7 +486,7 @@ describe("", () => { // click add button await userEvent.click(getByText(addLabel)); // wait for adding action - await findByText(httpReferrerRecipient.name); + await findByText(httpReferrerRecipient.name!); // click ok button to check the result await userEvent.click(getByText(okLabel)); diff --git a/react-front-end/__tests__/tsrc/modules/BrowserStorageModule.test.ts b/react-front-end/__tests__/tsrc/modules/BrowserStorageModule.test.ts index 51c9578bfe..8ab6d38f39 100644 --- a/react-front-end/__tests__/tsrc/modules/BrowserStorageModule.test.ts +++ b/react-front-end/__tests__/tsrc/modules/BrowserStorageModule.test.ts @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Boolean } from "runtypes"; +import * as t from "io-ts"; import { readDataFromStorage, saveDataToStorage, @@ -25,7 +25,7 @@ const mockGetItem = jest.spyOn(Storage.prototype, "getItem"); const mockSetItem = jest .spyOn(Storage.prototype, "setItem") .mockImplementation(jest.fn); -const booleanValidator = Boolean.guard; +const booleanValidator = t.boolean.is; const mockConsoleError = jest.spyOn(console, "error"); const KEY = "test"; diff --git a/react-front-end/__tests__/tsrc/modules/SearchFacetsModule.test.ts b/react-front-end/__tests__/tsrc/modules/SearchFacetsModule.test.ts index 45905dd8c8..efc96832dd 100644 --- a/react-front-end/__tests__/tsrc/modules/SearchFacetsModule.test.ts +++ b/react-front-end/__tests__/tsrc/modules/SearchFacetsModule.test.ts @@ -104,7 +104,7 @@ describe("SearchFacetsModule", () => { firstName: "Test", lastName: "Owner", }, - status: OEQ.Common.ItemStatuses.alternatives.map((i) => i.value), // i.e. All statuses + status: OEQ.Codec.Common.ItemStatusCodec.types.map(({ value }) => value), // i.e. All statuses sortOrder: undefined, mimeTypes: mimeTypes, }); diff --git a/react-front-end/__tests__/tsrc/util/Either.extended.test.ts b/react-front-end/__tests__/tsrc/util/Either.extended.test.ts new file mode 100644 index 0000000000..7c5ef8e0f7 --- /dev/null +++ b/react-front-end/__tests__/tsrc/util/Either.extended.test.ts @@ -0,0 +1,37 @@ +/* + * Licensed to The Apereo Foundation under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * The Apereo Foundation licenses this file to you under the Apache License, + * Version 2.0, (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { pipe } from "fp-ts/function"; +import * as E from "../../../tsrc/util/Either.extended"; + +describe("Either Extended", () => { + describe("getOrThrow", () => { + it("should return the right value if Either is right", () => { + const rightValue = "Right Value"; + const result = pipe(E.right(rightValue), E.getOrThrow); + + expect(result).toEqual(rightValue); + }); + + it("should throw an error if Either is left", () => { + const leftValue = "Error Message"; + expect(() => pipe(E.left(leftValue), E.getOrThrow)).toThrow( + new Error(leftValue) + ); + }); + }); +}); diff --git a/react-front-end/__tests__/tsrc/util/match.test.ts b/react-front-end/__tests__/tsrc/util/match.test.ts index 5fb969c676..9c780a94b8 100644 --- a/react-front-end/__tests__/tsrc/util/match.test.ts +++ b/react-front-end/__tests__/tsrc/util/match.test.ts @@ -16,7 +16,11 @@ * limitations under the License. */ import { identity } from "fp-ts/function"; -import { simpleMatch, simpleMatchD } from "../../../tsrc/util/match"; +import { + simpleMatch, + simpleMatchD, + simpleUnionMatch, +} from "../../../tsrc/util/match"; describe("simpleMatch", () => { const unmatched = (s: string | number): string => @@ -63,3 +67,33 @@ describe("simpleMatchDynamic", () => { expect(testMatchD(knownMatch)).toMatch(`${knownMatch}`); }); }); + +describe("simpleUnionTypeMatch", () => { + type TestUnion = "hello" | "world" | 1; + const cases: Partial string>> = { + hello: () => "hello", + 1: () => "1", + }; + const defaultValue = "default"; + const buildDefault = jest.fn().mockReturnValue(defaultValue); + const testMatch = simpleUnionMatch(cases, buildDefault); + + it("executes the function matching the provided literal type", () => { + const knownMatch = "hello"; + expect(testMatch(knownMatch)).toMatch(knownMatch); + }); + + it("executes the default function if the literal type does not have any function to be executed", () => { + const knownMatch = "world"; + const result = testMatch(knownMatch); + expect(buildDefault).toHaveBeenCalledTimes(1); + expect(result).toMatch(defaultValue); + }); + + it("throws an error if the literal type does not have any function to be executed", () => { + const noMatcher = simpleUnionMatch(cases); + expect(() => noMatcher("world")).toThrow( + "Missing matcher for literal type: world" + ); + }); +}); diff --git a/react-front-end/package-lock.json b/react-front-end/package-lock.json index 3c2bfa36ad..97f2151745 100644 --- a/react-front-end/package-lock.json +++ b/react-front-end/package-lock.json @@ -26,6 +26,7 @@ "history": "4.10.1", "html-react-parser": "3.0.16", "io-ts": "2.2.20", + "io-ts-types": "0.5.19", "jspolyfill-array.prototype.find": "0.1.3", "lodash": "4.17.21", "luxon": "3.4.2", @@ -40,7 +41,6 @@ "react-router": "5.3.4", "react-router-dom": "5.3.4", "react-router-hash-link": "2.4.3", - "runtypes": "6.7.0", "shallow-equal-object": "1.1.1", "sprintf-js": "1.1.2", "tinymce": "6.7.0", @@ -113,7 +113,6 @@ "monocle-ts": "2.3.13", "newtype-ts": "0.3.5", "query-string": "^7.0.0", - "runtypes": "6.7.0", "tough-cookie": "^4.0.0" }, "devDependencies": { @@ -4724,10 +4723,6 @@ "queue-microtask": "^1.2.2" } }, - "../oeq-ts-rest-api/node_modules/runtypes": { - "version": "6.6.0", - "license": "MIT" - }, "../oeq-ts-rest-api/node_modules/semver": { "version": "7.3.8", "dev": true, @@ -19781,6 +19776,17 @@ "fp-ts": "^2.5.0" } }, + "node_modules/io-ts-types": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/io-ts-types/-/io-ts-types-0.5.19.tgz", + "integrity": "sha512-kQOYYDZG5vKre+INIDZbLeDJe+oM+4zLpUkjXyTMyUfoCpjJNyi29ZLkuEAwcPufaYo3yu/BsemZtbdD+NtRfQ==", + "peerDependencies": { + "fp-ts": "^2.0.0", + "io-ts": "^2.0.0", + "monocle-ts": "^2.0.0", + "newtype-ts": "^0.3.2" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -23181,6 +23187,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/monocle-ts": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/monocle-ts/-/monocle-ts-2.3.13.tgz", + "integrity": "sha512-D5Ygd3oulEoAm3KuGO0eeJIrhFf1jlQIoEVV2DYsZUMz42j4tGxgct97Aq68+F8w4w4geEnwFa8HayTS/7lpKQ==", + "peer": true, + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -23330,6 +23345,16 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/newtype-ts": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/newtype-ts/-/newtype-ts-0.3.5.tgz", + "integrity": "sha512-v83UEQMlVR75yf1OUdoSFssjitxzjZlqBAjiGQ4WJaML8Jdc68LJ+BaSAXUmKY4bNzp7hygkKLYTsDi14PxI2g==", + "peer": true, + "peerDependencies": { + "fp-ts": "^2.0.0", + "monocle-ts": "^2.0.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "dev": true, @@ -27071,11 +27096,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/runtypes": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/runtypes/-/runtypes-6.7.0.tgz", - "integrity": "sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==" - }, "node_modules/safe-buffer": { "version": "5.1.2", "dev": true, @@ -33210,7 +33230,6 @@ "query-string": "^7.0.0", "rollup": "3.29.0", "rollup-plugin-typescript2": "0.35.0", - "runtypes": "6.7.0", "tough-cookie": "^4.0.0", "ts-jest": "29.1.1", "tslib": "2.6.2", @@ -36129,9 +36148,6 @@ "queue-microtask": "^1.2.2" } }, - "runtypes": { - "version": "6.6.0" - }, "semver": { "version": "7.3.8", "dev": true, @@ -43722,6 +43738,12 @@ "version": "2.2.20", "requires": {} }, + "io-ts-types": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/io-ts-types/-/io-ts-types-0.5.19.tgz", + "integrity": "sha512-kQOYYDZG5vKre+INIDZbLeDJe+oM+4zLpUkjXyTMyUfoCpjJNyi29ZLkuEAwcPufaYo3yu/BsemZtbdD+NtRfQ==", + "requires": {} + }, "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -46090,6 +46112,13 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "monocle-ts": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/monocle-ts/-/monocle-ts-2.3.13.tgz", + "integrity": "sha512-D5Ygd3oulEoAm3KuGO0eeJIrhFf1jlQIoEVV2DYsZUMz42j4tGxgct97Aq68+F8w4w4geEnwFa8HayTS/7lpKQ==", + "peer": true, + "requires": {} + }, "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -46192,6 +46221,13 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "newtype-ts": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/newtype-ts/-/newtype-ts-0.3.5.tgz", + "integrity": "sha512-v83UEQMlVR75yf1OUdoSFssjitxzjZlqBAjiGQ4WJaML8Jdc68LJ+BaSAXUmKY4bNzp7hygkKLYTsDi14PxI2g==", + "peer": true, + "requires": {} + }, "nice-try": { "version": "1.0.5", "dev": true @@ -48888,11 +48924,6 @@ "queue-microtask": "^1.2.2" } }, - "runtypes": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/runtypes/-/runtypes-6.7.0.tgz", - "integrity": "sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==" - }, "safe-buffer": { "version": "5.1.2", "dev": true diff --git a/react-front-end/package.json b/react-front-end/package.json index 085c442080..4b7dd58ed6 100644 --- a/react-front-end/package.json +++ b/react-front-end/package.json @@ -49,6 +49,7 @@ "history": "4.10.1", "html-react-parser": "3.0.16", "io-ts": "2.2.20", + "io-ts-types": "0.5.19", "jspolyfill-array.prototype.find": "0.1.3", "lodash": "4.17.21", "luxon": "3.4.2", @@ -63,7 +64,6 @@ "react-router": "5.3.4", "react-router-dom": "5.3.4", "react-router-hash-link": "2.4.3", - "runtypes": "6.7.0", "shallow-equal-object": "1.1.1", "sprintf-js": "1.1.2", "tinymce": "6.7.0", diff --git a/react-front-end/tsrc/components/IPv4CIDRInput.tsx b/react-front-end/tsrc/components/IPv4CIDRInput.tsx index a93ff95605..e34d578e3d 100644 --- a/react-front-end/tsrc/components/IPv4CIDRInput.tsx +++ b/react-front-end/tsrc/components/IPv4CIDRInput.tsx @@ -15,30 +15,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import { Grid, TextField } from "@mui/material"; import { styled } from "@mui/material/styles"; import * as A from "fp-ts/Array"; +import * as E from "fp-ts/Either"; import { constant, constFalse, pipe } from "fp-ts/function"; import * as O from "fp-ts/Option"; import { not } from "fp-ts/Predicate"; import * as S from "fp-ts/string"; import * as React from "react"; import { createRef, RefObject, useState, useRef } from "react"; -import { Literal, Union } from "runtypes"; import * as N from "fp-ts/number"; import * as NEA from "fp-ts/NonEmptyArray"; import * as RA from "fp-ts/ReadonlyArray"; import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"; +import { simpleUnionMatch } from "../util/match"; /** * Runtypes definition for key code which need to be handled here. */ -const KeyCodeTypesUnion = Union( - Literal("Enter"), - Literal("Backspace"), - Literal("Period"), - Literal("NumpadDecimal") -); +const KeyCodeTypesUnion = t.union([ + t.literal("Enter"), + t.literal("Backspace"), + t.literal("Period"), + t.literal("NumpadDecimal"), +]); + +type KeyCodeTypes = t.TypeOf; const PREFIX = "IPv4CIDRInput"; const classes = { @@ -234,30 +238,31 @@ const IPv4CIDRInput = ({ value = "", onChange }: IPv4CIDRInputProps) => { ) => pipe( event.code, - O.fromPredicate(KeyCodeTypesUnion.guard), - O.map( - KeyCodeTypesUnion.match( - (Enter) => focusInput(index + 1), - (Backspace) => { + KeyCodeTypesUnion.decode, + E.fold( + console.error, + simpleUnionMatch({ + Enter: () => focusInput(index + 1), + Backspace: () => { if (S.isEmpty(inputValue)) { focusInput(index - 1); } }, - (Period) => { + Period: () => { if (!S.isEmpty(inputValue)) { focusInput(index + 1); // key `Period` will trigger focus event on current input, thus prevent it. event.preventDefault(); } }, - (NumpadDecimal) => { + NumpadDecimal: () => { if (!S.isEmpty(inputValue)) { focusInput(index + 1); // key `NumpadDecimal` will trigger focus event on current input. event.preventDefault(); } - } - ) + }, + }) ) ); diff --git a/react-front-end/tsrc/components/aclexpressionbuilder/ACLHTTPReferrerInput.tsx b/react-front-end/tsrc/components/aclexpressionbuilder/ACLHTTPReferrerInput.tsx index d8c1aeca57..791df504a5 100644 --- a/react-front-end/tsrc/components/aclexpressionbuilder/ACLHTTPReferrerInput.tsx +++ b/react-front-end/tsrc/components/aclexpressionbuilder/ACLHTTPReferrerInput.tsx @@ -15,9 +15,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import { FormControlLabel, Radio, RadioGroup, TextField } from "@mui/material"; import * as React from "react"; -import { Literal, Static, Union } from "runtypes"; import { languageStrings } from "../../util/langstrings"; /** @@ -25,8 +25,8 @@ import { languageStrings } from "../../util/langstrings"; * Contain: Match referrers containing this value. * Exact: Only match this exact referrer. */ -const ReferrerTypesUnion = Union(Literal("Contain"), Literal("Exact")); -export type ReferrerType = Static; +const ReferrerTypesUnion = t.union([t.literal("Contain"), t.literal("Exact")]); +export type ReferrerType = t.TypeOf; const { aclExpressionBuilder: { diff --git a/react-front-end/tsrc/components/aclexpressionbuilder/ACLHomePanel.tsx b/react-front-end/tsrc/components/aclexpressionbuilder/ACLHomePanel.tsx index 8992c1c48f..91c77d476f 100644 --- a/react-front-end/tsrc/components/aclexpressionbuilder/ACLHomePanel.tsx +++ b/react-front-end/tsrc/components/aclexpressionbuilder/ACLHomePanel.tsx @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import { FormControl, FormControlLabel, @@ -23,12 +24,12 @@ import { RadioGroup, Radio, } from "@mui/material"; +import * as E from "../../util/Either.extended"; import * as React from "react"; import * as OEQ from "@openequella/rest-api-client"; import { pipe } from "fp-ts/function"; import * as RSET from "fp-ts/ReadonlySet"; import { ChangeEvent, useState } from "react"; -import { Literal, Static, Union } from "runtypes"; import { ACLRecipient, groupToRecipient, @@ -37,6 +38,7 @@ import { userToRecipient, } from "../../modules/ACLRecipientModule"; import { languageStrings } from "../../util/langstrings"; +import { simpleUnionMatch } from "../../util/match"; import GroupSearch from "../securityentitysearch/GroupSearch"; import RoleSearch from "../securityentitysearch/RoleSearch"; import UserSearch from "../securityentitysearch/UserSearch"; @@ -44,13 +46,13 @@ import UserSearch from "../securityentitysearch/UserSearch"; /** * Runtypes definition for home panel search filter type. */ -const SearchFilterTypesUnion = Union( - Literal("Users"), - Literal("Groups"), - Literal("Roles") -); +const SearchFilterTypesUnion = t.union([ + t.literal("Users"), + t.literal("Groups"), + t.literal("Roles"), +]); -type SearchFilterType = Static; +type SearchFilterType = t.TypeOf; const { aclExpressionBuilder: { type: typeLabel }, @@ -99,7 +101,12 @@ const ACLHomePanel = ({ useState("Users"); const handleSearchFilterChange = (event: ChangeEvent) => - setActiveSearchFilterType(SearchFilterTypesUnion.check(event.target.value)); + pipe( + event.target.value, + SearchFilterTypesUnion.decode, + E.getOrThrow, + setActiveSearchFilterType + ); const handleOnAdded = ( selections: ReadonlySet, @@ -127,7 +134,7 @@ const ACLHomePanel = ({ value={activeSearchFilterType} onChange={handleSearchFilterChange} > - {SearchFilterTypesUnion.alternatives.map((searchType) => ( + {SearchFilterTypesUnion.types.map((searchType) => ( {pipe( activeSearchFilterType, - SearchFilterTypesUnion.match( - (Users) => ( + simpleUnionMatch({ + Users: () => ( @@ -155,9 +162,9 @@ const ACLHomePanel = ({ showHelpText /> ), - (Groups) => ( + Groups: () => ( @@ -169,9 +176,9 @@ const ACLHomePanel = ({ showHelpText /> ), - (Roles) => ( + Roles: () => ( @@ -184,8 +191,8 @@ const ACLHomePanel = ({ groupFilterEditable={false} showHelpText /> - ) - ) + ), + }) )} ); diff --git a/react-front-end/tsrc/components/aclexpressionbuilder/ACLOtherPanel.tsx b/react-front-end/tsrc/components/aclexpressionbuilder/ACLOtherPanel.tsx index cbc119518e..27c227dd6d 100644 --- a/react-front-end/tsrc/components/aclexpressionbuilder/ACLOtherPanel.tsx +++ b/react-front-end/tsrc/components/aclexpressionbuilder/ACLOtherPanel.tsx @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import { Button, FormControl, @@ -25,13 +26,12 @@ import { SelectChangeEvent, Typography, } from "@mui/material"; -import * as E from "fp-ts/Either"; +import * as E from "../../util/Either.extended"; import { flow, identity, pipe } from "fp-ts/function"; import * as O from "fp-ts/Option"; import * as TE from "fp-ts/TaskEither"; import * as React from "react"; import { ReactNode, useCallback, useState } from "react"; -import { Literal, Static, Union } from "runtypes"; import { ACLEntityResolvers } from "../../modules/ACLEntityModule"; import { ACLRecipient, @@ -45,6 +45,7 @@ import { findGroupById } from "../../modules/GroupModule"; import { findRoleById } from "../../modules/RoleModule"; import { findUserById } from "../../modules/UserModule"; import { languageStrings } from "../../util/langstrings"; +import { simpleUnionMatch } from "../../util/match"; import IPv4CIDRInput from "../IPv4CIDRInput"; import ACLHTTPReferrerInput from "./ACLHTTPReferrerInput"; import ACLSSOMenu from "./ACLSSOMenu"; @@ -76,16 +77,16 @@ const { /** * Runtypes definition for ACL expression types in `other` panel. */ -const OtherACLTypesUnion = Union( - Literal("Everyone"), - Literal("Owner"), - Literal("Logged"), - Literal("Guest"), - Literal("SSO"), - Literal("IP"), - Literal("Referrer") -); -type OtherACLType = Static; +const OtherACLTypesUnion = t.union([ + t.literal("Everyone"), + t.literal("Owner"), + t.literal("Logged"), + t.literal("Guest"), + t.literal("SSO"), + t.literal("IP"), + t.literal("Referrer"), +]); +type OtherACLType = t.TypeOf; export interface ACLOtherPanelProps { /** @@ -125,20 +126,30 @@ const ACLOtherPanel = ({ const [ssoToken, setSSOToken] = useState(""); const handleAclTypeChanged = (event: SelectChangeEvent) => - setActiveACLType(OtherACLTypesUnion.check(event.target.value)); + pipe( + event.target.value, + OtherACLTypesUnion.decode, + E.getOrThrow, + setActiveACLType + ); const handleAddButtonClicked = async () => { // generate raw ACL expression string for corresponding recipient type - const generateExpression = OtherACLTypesUnion.match( - (everyone) => ACLRecipientTypes.Everyone, - (owner) => ACLRecipientTypes.Owner, - (logged) => `${ACLRecipientTypes.Role}:${LOGGED_IN_USER_ROLE_ID}`, - (guest) => `${ACLRecipientTypes.Role}:${GUEST_USER_ROLE_ID}`, - (sso) => (ssoToken ? `${ACLRecipientTypes.Sso}:${ssoToken}` : undefined), - (ip) => (ipAddress ? `${ACLRecipientTypes.Ip}:${ipAddress}` : undefined), - (referrer) => - httpReferrer ? `${ACLRecipientTypes.Refer}:${httpReferrer}` : undefined - ); + const generateExpression = simpleUnionMatch< + OtherACLType, + string | undefined + >({ + Everyone: () => ACLRecipientTypes.Everyone, + Owner: () => ACLRecipientTypes.Owner, + Logged: () => `${ACLRecipientTypes.Role}:${LOGGED_IN_USER_ROLE_ID}`, + Guest: () => `${ACLRecipientTypes.Role}:${GUEST_USER_ROLE_ID}`, + SSO: () => + ssoToken ? `${ACLRecipientTypes.Sso}:${ssoToken}` : undefined, + IP: () => + ipAddress ? `${ACLRecipientTypes.Ip}:${ipAddress}` : undefined, + Referrer: () => + httpReferrer ? `${ACLRecipientTypes.Refer}:${httpReferrer}` : undefined, + }); // create an ACLRecipient object const optionRecipient: O.Option = pipe( @@ -174,15 +185,19 @@ const ACLOtherPanel = ({ ); - const buildSelections = OtherACLTypesUnion.match( - (Everyone) => selection(Everyone, everyoneLabel), - (Owner) => selection(Owner, ownerLabel), - (Logged) => selection(Logged, loggedLabel), - (Guest) => selection(Guest, guestLabel), - (Sso) => selection(Sso, ssoLabel), - (Ip) => selection(Ip, ipLabel), - (Referrer) => selection(Referrer, referrerLabel) - ); + const buildSelections = (aclType: OtherACLType): React.JSX.Element => { + const getLabel = simpleUnionMatch({ + Everyone: () => everyoneLabel, + Owner: () => ownerLabel, + Logged: () => loggedLabel, + Guest: () => guestLabel, + SSO: () => ssoLabel, + IP: () => ipLabel, + Referrer: () => referrerLabel, + }); + + return pipe(aclType, getLabel, (label) => selection(aclType, label)); + }; const OtherControl = ({ title, @@ -204,15 +219,15 @@ const ACLOtherPanel = ({ * TODO: Get all SSO selects from API * */ const buildControls = useCallback( - () => + (): React.JSX.Element => pipe( activeACLType, - OtherACLTypesUnion.match( - (Everyone) => , - (Owner) => , - (Logged) => , - (Guest) => , - (Sso) => ( + simpleUnionMatch({ + Everyone: () => , + Owner: () => , + Logged: () => , + Guest: () => , + SSO: () => ( ), - (Ip) => ( + IP: () => ( ), - (Referrer) => ( + Referrer: () => ( - ) - ) + ), + }) ), [activeACLType, ssoTokensProvider] ); @@ -246,7 +261,7 @@ const ACLOtherPanel = ({ onChange={handleAclTypeChanged} label={typeLabel} > - {OtherACLTypesUnion.alternatives.map((aclType) => + {OtherACLTypesUnion.types.map((aclType) => buildSelections(aclType.value) )} diff --git a/react-front-end/tsrc/components/wizard/WizardHelper.tsx b/react-front-end/tsrc/components/wizard/WizardHelper.tsx index b63f11a584..47b289adc2 100644 --- a/react-front-end/tsrc/components/wizard/WizardHelper.tsx +++ b/react-front-end/tsrc/components/wizard/WizardHelper.tsx @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import * as OEQ from "@openequella/rest-api-client"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; @@ -39,16 +40,6 @@ import { first } from "fp-ts/Semigroup"; import * as SEP from "fp-ts/Separated"; import * as S from "fp-ts/string"; import * as React from "react"; -import { - Array as RuntypeArray, - Boolean, - Number, - Optional, - Record, - Static, - String, - Union, -} from "runtypes"; import { buildVisibilityScript, ScriptContext, @@ -103,40 +94,35 @@ export interface WizardControlBasicProps { /** * Runtypes definition for ControlTarget. */ -export const RuntypesControlTarget = Record({ - /** - * The 'fullPath's for the targetNode. - */ - schemaNode: RuntypeArray(String), - /** - * The type of control that is being targeted. - */ - type: OEQ.WizardControl.RuntypesControlType, - /** - * Whether to tokenise the value. - */ - isValueTokenised: Optional(Boolean), -}); +export const ControlTargetCodec = t.intersection([ + t.type({ + schemaNode: t.array(t.string), + type: OEQ.Codec.WizardControl.ControlTypeCodec, + }), + t.partial({ + isValueTokenised: t.boolean, + }), +]); /** * Used to loosely target what a value (typically a `ControlValue`) is being used for. */ -export type ControlTarget = Static; +export type ControlTarget = t.TypeOf; /** * Runtypes definition for ControlValue. */ -export const RuntypesControlValue = Union( - RuntypeArray(String), - RuntypeArray(Number) -); +export const ControlValueCodec = t.union([ + t.array(t.string), + t.array(t.number), +]); /** * Convenience type for our way of storing the two main value types across our controls. Represents * that some controls are textual, and some are numeric; and that some controls store more than one * value. */ -export type ControlValue = Static; +export type ControlValue = t.TypeOf; /** * Identifies a Wizard 'field' and specifies its value. diff --git a/react-front-end/tsrc/modules/ACLExpressionModule.ts b/react-front-end/tsrc/modules/ACLExpressionModule.ts index 240e0f9253..eb4d917582 100644 --- a/react-front-end/tsrc/modules/ACLExpressionModule.ts +++ b/react-front-end/tsrc/modules/ACLExpressionModule.ts @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; import { constant, flow, identity, pipe } from "fp-ts/function"; @@ -28,9 +29,9 @@ import { ReadonlyNonEmptyArray } from "fp-ts/ReadonlyNonEmptyArray"; import * as S from "fp-ts/string"; import * as T from "fp-ts/Task"; import * as TE from "fp-ts/TaskEither"; -import { Literal, Static, Union } from "runtypes"; import { v4 as uuidv4 } from "uuid"; import { languageStrings } from "../util/langstrings"; +import { simpleUnionMatch } from "../util/match"; import { pfTernary, pfTernaryTypeGuard } from "../util/pointfree"; import { ACLEntityResolversMulti, ACLEntityResolvers } from "./ACLEntityModule"; import { @@ -54,21 +55,21 @@ const { * * `UNKNOWN` is only used as a placeholder while parsing an ACL recipient string. */ -export const ACLOperatorTypesUnion = Union( - Literal("OR"), - Literal("AND"), - Literal("NOT"), - Literal("UNKNOWN") -); +export const ACLOperatorTypesUnion = t.union([ + t.literal("OR"), + t.literal("AND"), + t.literal("NOT"), + t.literal("UNKNOWN"), +]); -export type ACLOperatorType = Static; +export type ACLOperatorType = t.TypeOf; -export const getOperatorLabel = ACLOperatorTypesUnion.match( - (OR) => orLabel, - (AND) => andLabel, - (NOT) => notLabel, - (UNKNOWN) => UNKNOWN -); +export const getOperatorLabel = simpleUnionMatch({ + OR: () => orLabel, + AND: () => andLabel, + NOT: () => notLabel, + UNKNOWN: () => "UNKNOWN", +}); /** * Represents the ACL Expression string. @@ -256,7 +257,7 @@ const getChildrenCount = (expression: ACLExpression): number => * Tells whether the provide piece of text is an ACL Operator. */ const isACLOperator = (text: string): text is ACLOperatorType => - ACLOperatorTypesUnion.guard(text); + ACLOperatorTypesUnion.is(text); /** * Merge a given base ACL Expression with a new ACL Expression. diff --git a/react-front-end/tsrc/modules/ACLRecipientModule.ts b/react-front-end/tsrc/modules/ACLRecipientModule.ts index 2f43eca51f..ccfa971b0f 100644 --- a/react-front-end/tsrc/modules/ACLRecipientModule.ts +++ b/react-front-end/tsrc/modules/ACLRecipientModule.ts @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import * as OEQ from "@openequella/rest-api-client"; import * as E from "fp-ts/Either"; import * as EQ from "fp-ts/Eq"; @@ -24,9 +25,22 @@ import * as ORD from "fp-ts/Ord"; import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"; import * as S from "fp-ts/string"; import * as TE from "fp-ts/TaskEither"; -import { Literal, Static, Union } from "runtypes"; +import { simpleUnionMatch } from "../util/match"; import { ACLEntityResolvers } from "./ACLEntityModule"; +const ACLRecipientTypesUnion = t.union([ + t.literal("U"), + t.literal("G"), + t.literal("R"), + t.literal("*"), + t.literal("$OWNER"), + t.literal("F"), + t.literal("I"), + t.literal("T"), +]); + +export type ACLRecipientType = t.TypeOf; + /** * ACL Recipient types: * * user: U:xxx-xxx-xxx @@ -38,7 +52,7 @@ import { ACLEntityResolvers } from "./ACLEntityModule"; * * IP: I:255.255.0.0%2F24 * * Sso: T:xxx */ -export const ACLRecipientTypes = { +export const ACLRecipientTypes: { [prefix: string]: ACLRecipientType } = { User: "U", Group: "G", Role: "R", @@ -49,19 +63,6 @@ export const ACLRecipientTypes = { Sso: "T", }; -const ACLRecipientTypesUnion = Union( - Literal(ACLRecipientTypes.User), - Literal(ACLRecipientTypes.Group), - Literal(ACLRecipientTypes.Role), - Literal(ACLRecipientTypes.Everyone), - Literal(ACLRecipientTypes.Owner), - Literal(ACLRecipientTypes.Refer), - Literal(ACLRecipientTypes.Ip), - Literal(ACLRecipientTypes.Sso) -); - -export type ACLRecipientType = Static; - /** * Represents a recipient in the ACL expression. * @@ -162,13 +163,11 @@ const generateACLRecipientName = resolveGroupProvider, resolveRoleProvider, }: ACLEntityResolvers) => - (recipient: ACLRecipient): TE.TaskEither => { - const { type, expression } = recipient; - - return pipe( + ({ type, expression }: ACLRecipient): TE.TaskEither => + pipe( type, - ACLRecipientTypesUnion.match( - (User) => + simpleUnionMatch>({ + U: () => pipe( TE.tryCatch( () => resolveUserProvider(expression), @@ -183,7 +182,7 @@ const generateACLRecipientName = ) ) ), - (Group) => + G: () => pipe( TE.tryCatch( () => resolveGroupProvider(expression), @@ -193,7 +192,7 @@ const generateACLRecipientName = (g: OEQ.UserQuery.GroupDetails | undefined) => g?.name ) ), - (Role) => + R: () => pipe( TE.tryCatch( () => resolveRoleProvider(expression), @@ -203,14 +202,13 @@ const generateACLRecipientName = (r: OEQ.UserQuery.RoleDetails | undefined) => r?.name ) ), - (Everyone) => TE.right("Everyone"), - ($OWNER) => TE.right("Owner"), - (Ip) => TE.right("From " + decodeURIComponent(expression)), - (Refer) => TE.right("From " + expression), - (Sso) => TE.right("Token ID is " + expression) - ) + "*": () => TE.right("Everyone"), + $OWNER: () => TE.right("Owner"), + I: () => TE.right("From " + expression), + F: () => TE.right("From " + decodeURIComponent(expression)), + T: () => TE.right("Token ID is " + expression), + }) ); - }; /** * Show the full raw expression string for an ACL Recipient. @@ -245,7 +243,7 @@ const parseACLRecipientType = ( S.split(":"), RNEA.head, E.fromPredicate( - ACLRecipientTypesUnion.guard, + ACLRecipientTypesUnion.is, (invalid) => `Failed to parse recipient: ${invalid}` ) ); diff --git a/react-front-end/tsrc/modules/LegacyContentModule.ts b/react-front-end/tsrc/modules/LegacyContentModule.ts index f16f42ee95..e9a6ee9254 100644 --- a/react-front-end/tsrc/modules/LegacyContentModule.ts +++ b/react-front-end/tsrc/modules/LegacyContentModule.ts @@ -15,28 +15,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import Axios from "axios"; -import { Literal, Static, Union } from "runtypes"; import { API_BASE_URL } from "../AppConfig"; import type { ChangeRoute } from "../legacycontent/LegacyContent"; import type { ScrapbookType } from "./ScrapbookModule"; const legacyMyResourcesUrl = `${API_BASE_URL}/content/submit/access/myresources.do`; -export const ScrapbookLiteral = Literal("scrapbook"); -export const ModQueueLiteral = Literal("modqueue"); +export const ScrapbookLiteral = t.literal("scrapbook"); +export const ModQueueLiteral = t.literal("modqueue"); -export const LegacyMyResourcesRuntypes = Union( - Literal("published"), - Literal("draft"), +export const LegacyMyResourcesCodec = t.union([ + t.literal("published"), + t.literal("draft"), ScrapbookLiteral, ModQueueLiteral, - Literal("archived"), - Literal("all"), - Literal("defaultValue") -); + t.literal("archived"), + t.literal("all"), + t.literal("defaultValue"), +]); -export type LegacyMyResourcesTypes = Static; +export type LegacyMyResourcesTypes = t.TypeOf; /** * Send a Legacy content request to trigger server side event 'contributeFromNewUI', which will * return a route for accessing the Legacy Scrapbook creating page. To support the requirement diff --git a/react-front-end/tsrc/modules/SearchFacetsModule.ts b/react-front-end/tsrc/modules/SearchFacetsModule.ts index c30b8330bd..cfed3d3998 100644 --- a/react-front-end/tsrc/modules/SearchFacetsModule.ts +++ b/react-front-end/tsrc/modules/SearchFacetsModule.ts @@ -105,7 +105,7 @@ const convertSearchOptions: ( owner: owner?.id, showall: isEqual( status?.sort(), - OEQ.Common.ItemStatuses.alternatives.map((i) => i.value).sort() + OEQ.Codec.Common.ItemStatusCodec.types.map(({ value }) => value).sort() ), mimeTypes, musts, diff --git a/react-front-end/tsrc/modules/SearchModule.ts b/react-front-end/tsrc/modules/SearchModule.ts index c930c0cc8f..95b0993764 100644 --- a/react-front-end/tsrc/modules/SearchModule.ts +++ b/react-front-end/tsrc/modules/SearchModule.ts @@ -16,14 +16,14 @@ * limitations under the License. */ +import * as t from "io-ts"; import * as OEQ from "@openequella/rest-api-client"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; -import { flow, pipe } from "fp-ts/function"; +import { flow, pipe, constFalse } from "fp-ts/function"; import * as NEA from "fp-ts/NonEmptyArray"; import * as O from "fp-ts/Option"; import * as TE from "fp-ts/TaskEither"; -import { Literal, Static, Union } from "runtypes"; import { API_BASE_URL } from "../AppConfig"; import { DateRange, getISODateString } from "../util/Date"; import type { Collection } from "./CollectionsModule"; @@ -50,41 +50,39 @@ export const nonLiveStatus = (status: OEQ.Common.ItemStatus): boolean => * List of statuses which are considered non-live. */ export const nonLiveStatuses: OEQ.Common.ItemStatus[] = - OEQ.Common.ItemStatuses.alternatives - .map((status) => status.value) + OEQ.Codec.Common.ItemStatusCodec.types + .map(({ value }) => value) .filter(nonLiveStatus); /** * All statuses except "DELETED". */ export const nonDeletedStatuses: OEQ.Common.ItemStatus[] = - OEQ.Common.ItemStatuses.alternatives - .map((status) => status.value) + OEQ.Codec.Common.ItemStatusCodec.types + .map(({ value }) => value) .filter((status) => status !== "DELETED"); /** * Function to check if the supplied SearchResultItem refers to a live Item. + * Item status returned from 'search2' is a lowercase string so convert it to uppercase. */ -export const isLiveItem = (item: OEQ.Search.SearchResultItem): boolean => { - // Item status returned from 'search2' is a lowercase string so convert it to uppercase. - const status = item.status.toUpperCase(); - return OEQ.Common.ItemStatuses.guard(status) && liveStatuses.includes(status); -}; +export const isLiveItem = (item: OEQ.Search.SearchResultItem): boolean => + pipe( + OEQ.Codec.Common.ItemStatusCodec.decode(item.status.toUpperCase()), + E.fold(constFalse, (s) => liveStatuses.includes(s)) + ); -/** - * A Runtypes object which represents three display modes: list, gallery-image and gallery-video. - */ -export const DisplayModeRuntypes = Union( - Literal("list"), - Literal("gallery-image"), - Literal("gallery-video") -); +export const DisplayModeCodec = t.union([ + t.literal("list"), + t.literal("gallery-image"), + t.literal("gallery-video"), +]); /** * Available modes for displaying search results. * @see { @link DisplayModeRuntypes } for original definition. */ -export type DisplayMode = Static; +export type DisplayMode = t.TypeOf; /** * Type of all search options on Search page diff --git a/react-front-end/tsrc/myresources/MyResourcesPageHelper.tsx b/react-front-end/tsrc/myresources/MyResourcesPageHelper.tsx index 17fde72556..b0f0468c6c 100644 --- a/react-front-end/tsrc/myresources/MyResourcesPageHelper.tsx +++ b/react-front-end/tsrc/myresources/MyResourcesPageHelper.tsx @@ -32,15 +32,12 @@ import { Location } from "history"; import { MD5 } from "object-hash"; import * as React from "react"; import { ReactNode } from "react"; -import { Literal, match, Static, Union, Unknown, when } from "runtypes"; import { getBaseUrl } from "../AppConfig"; import { TooltipIconButton } from "../components/TooltipIconButton"; import { buildStorageKey } from "../modules/BrowserStorageModule"; import { - LegacyMyResourcesRuntypes, + LegacyMyResourcesCodec, LegacyMyResourcesTypes, - ModQueueLiteral, - ScrapbookLiteral, } from "../modules/LegacyContentModule"; import { getMimeTypeDefaultViewerDetails } from "../modules/MimeTypesModule"; import { @@ -59,27 +56,27 @@ import { SortOrderOptions } from "../search/components/SearchOrderSelect"; import SearchResult from "../search/components/SearchResult"; import { DehydratedSearchPageOptions, - DehydratedSearchPageOptionsRunTypes, + DehydratedSearchPageOptionsCodec, SearchPageOptions, } from "../search/SearchPageHelper"; import { SearchPageSearchResult } from "../search/SearchPageReducer"; import { DateRangeFromString, getISODateString } from "../util/Date"; import { languageStrings } from "../util/langstrings"; -import { simpleMatch } from "../util/match"; +import { simpleMatch, simpleUnionMatch } from "../util/match"; import { pfSplitAt, pfTernaryTypeGuard } from "../util/pointfree"; export const PARAM_MYRESOURCES_TYPE = "myResourcesType"; -export const MyResourcesTypeRuntypes = Union( - Literal("Published"), - Literal("Drafts"), - Literal("Scrapbook"), - Literal("Moderation queue"), - Literal("Archive"), - Literal("All resources") -); +export const MyResourcesTypeRuntypes = t.union([ + t.literal("Published"), + t.literal("Drafts"), + t.literal("Scrapbook"), + t.literal("Moderation queue"), + t.literal("Archive"), + t.literal("All resources"), +]); -export type MyResourcesType = Static; +export type MyResourcesType = t.TypeOf; /** * Return a list of Item status that match the given MyResources type. @@ -134,7 +131,7 @@ const getLegacyMyResourceType = ( O.chainEitherK( flow( E.fromPredicate( - LegacyMyResourcesRuntypes.guard, + LegacyMyResourcesCodec.is, (value) => `Invalid legacy my resources type: ${value}` ), E.mapLeft(console.error) @@ -175,7 +172,7 @@ const getSearchOptionsFromQueryParam = ( t.UnknownRecord.decode, // Type of the parsed result should be a record where keys and values are unknown. E.map(processLastModifiedDateRange), E.filterOrElseW( - DehydratedSearchPageOptionsRunTypes.guard, + DehydratedSearchPageOptionsCodec.is, constant( "Parsed searchOptions is not a DehydratedSearchPageOptions - failed type check" ) @@ -203,15 +200,15 @@ const getMyResourcesTypeFromLegacyQueryParam = ( params, getLegacyMyResourceType, O.map( - LegacyMyResourcesRuntypes.match( - (published) => "Published", - (draft) => "Drafts", - (scrapbook) => "Scrapbook", - (modqueue) => "Moderation queue", - (archived) => "Archive", - (all) => "All resources", - (defaultValue) => "Published" - ) + simpleUnionMatch({ + published: constant("Published"), + draft: constant("Drafts"), + scrapbook: constant("Scrapbook"), + modqueue: constant("Moderation queue"), + archived: constant("Archive"), + all: constant("All resources"), + defaultValue: constant("Published"), + }) ) ); @@ -221,7 +218,7 @@ const getMyResourcesTypeFromNewUIQueryParam = ( ): O.Option => pipe( params.get(PARAM_MYRESOURCES_TYPE), - O.fromPredicate(MyResourcesTypeRuntypes.guard) + O.fromPredicate(MyResourcesTypeRuntypes.is) ); /** @@ -261,7 +258,7 @@ const getSubStatusFromLegacyQueryParam = ( O.chainEitherK( flow( E.fromPredicate( - OEQ.Common.ItemStatuses.guard, + OEQ.Codec.Common.ItemStatusCodec.is, (value) => `Invalid Item status: ${value}` ), E.mapLeft(console.error) @@ -309,10 +306,12 @@ const getSortOrderFromLegacyQueryParam = ( params, getLegacyMyResourceType, O.map( - match( - when(ScrapbookLiteral, constant("sbsort")), - when(ModQueueLiteral, constant("modsort")), - when(Unknown, constant("sort")) + simpleUnionMatch( + { + modqueue: constant("modsort"), + scrapbook: constant("sbsort"), + }, + constant("sort") ) ), O.chain((queryString) => pipe(params.get(queryString), O.fromNullable)), @@ -327,7 +326,7 @@ const getSortOrderFromLegacyQueryParam = ( O.chainEitherK( flow( E.fromPredicate( - OEQ.Search.SortOrderRunTypes.guard, + OEQ.Codec.Search.SortOrderCodec.is, (value) => `Invalid sort order: ${value}` ), E.mapLeft(console.error) diff --git a/react-front-end/tsrc/search/SearchPageHelper.ts b/react-front-end/tsrc/search/SearchPageHelper.ts index 2c910fbbd6..e3e3f2a495 100644 --- a/react-front-end/tsrc/search/SearchPageHelper.ts +++ b/react-front-end/tsrc/search/SearchPageHelper.ts @@ -15,6 +15,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; +import * as td from "io-ts-types"; import * as OEQ from "@openequella/rest-api-client"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; @@ -27,28 +29,13 @@ import * as TO from "fp-ts/TaskOption"; import { History, Location } from "history"; import { pick } from "lodash"; import { createContext } from "react"; -import { - Array as RuntypeArray, - Boolean, - Guard, - Literal, - match, - Number, - Partial, - Record, - Static, - String, - Tuple, - Union, - Unknown, -} from "runtypes"; import type { FieldValueMap, PathValueMap, } from "../components/wizard/WizardHelper"; import { - RuntypesControlTarget, - RuntypesControlValue, + ControlTargetCodec, + ControlValueCodec, } from "../components/wizard/WizardHelper"; import { NEW_SEARCH_PATH, routes } from "../mainui/routes"; import { @@ -60,7 +47,7 @@ import { Collection, findCollectionsByUuid, } from "../modules/CollectionsModule"; -import { LegacyMyResourcesRuntypes } from "../modules/LegacyContentModule"; +import { LegacyMyResourcesCodec } from "../modules/LegacyContentModule"; import { buildSelectionSessionItemSummaryLink, buildSelectionSessionSearchPageLink, @@ -70,12 +57,12 @@ import { getMimeTypeFiltersById } from "../modules/SearchFilterSettingsModule"; import { defaultSearchOptions, DisplayMode, - DisplayModeRuntypes, + DisplayModeCodec, SearchOptions, SearchOptionsFields, } from "../modules/SearchModule"; import { findUserById } from "../modules/UserModule"; -import { DateRange, isDate } from "../util/Date"; +import { DateRange } from "../util/Date"; import { languageStrings } from "../util/langstrings"; import { simpleMatch } from "../util/match"; import { pfSlice, pfTernary } from "../util/pointfree"; @@ -341,52 +328,47 @@ export const defaultPagedSearchResult: OEQ.Search.SearchResult; +const LegacySearchParamsCodec = t.union([ + t.literal("dp"), + t.literal("ds"), + t.literal("dr"), + t.literal("q"), + t.literal("sort"), + t.literal("owner"), + t.literal("in"), + t.literal("mt"), + t.literal("_int.mimeTypes"), + t.literal("type"), + t.literal("doc"), +]); + +type LegacyParams = t.TypeOf; /** * Represents the shape of data returned from generateQueryStringFromSearchOptions */ -export const DehydratedSearchPageOptionsRunTypes = Partial({ - query: String, - rowsPerPage: Number, - currentPage: Number, - sortOrder: OEQ.Search.SortOrderRunTypes, - collections: RuntypeArray(Record({ uuid: String })), - rawMode: Boolean, - lastModifiedDateRange: Partial({ start: Guard(isDate), end: Guard(isDate) }), - owner: Record({ id: String }), - status: RuntypeArray(OEQ.Common.ItemStatuses), - selectedCategories: RuntypeArray( - Record({ - id: Number, - categories: RuntypeArray(String), - }) - ), - searchAttachments: Boolean, - mimeTypeFilters: RuntypeArray(Record({ id: String })), - displayMode: DisplayModeRuntypes, - dateRangeQuickModeEnabled: Boolean, - advFieldValue: RuntypeArray( - Tuple(RuntypesControlTarget, RuntypesControlValue) +export const DehydratedSearchPageOptionsCodec = t.partial({ + query: t.string, + rowsPerPage: t.number, + currentPage: t.number, + sortOrder: OEQ.Codec.Search.SortOrderCodec, + collections: t.array(t.type({ uuid: t.string })), + rawMode: t.boolean, + lastModifiedDateRange: t.partial({ start: td.date, end: td.date }), + owner: t.type({ id: t.string }), + status: t.array(OEQ.Codec.Common.ItemStatusCodec), + selectedCategories: t.array( + t.type({ id: t.number, categories: t.array(t.string) }) ), + searchAttachments: t.boolean, + mimeTypeFilters: t.array(t.type({ id: t.string })), + displayMode: DisplayModeCodec, + dateRangeQuickModeEnabled: t.boolean, + advFieldValue: t.array(t.tuple([ControlTargetCodec, ControlValueCodec])), }); -export type DehydratedSearchPageOptions = Static< - typeof DehydratedSearchPageOptionsRunTypes +export type DehydratedSearchPageOptions = t.TypeOf< + typeof DehydratedSearchPageOptionsCodec >; /** @@ -410,9 +392,7 @@ export const generateSearchPageOptionsFromQueryString = async ( // For all else, return `undefined`. if (searchPageOptions) { return await newSearchQueryToSearchPageOptions(searchPageOptions); - } else if ( - Array.from(params.keys()).some((key) => LegacySearchParams.guard(key)) - ) { + } else if (Array.from(params.keys()).some(LegacySearchParamsCodec.is)) { return await legacyQueryStringToSearchPageOptions(params); } return undefined; @@ -435,28 +415,18 @@ export const generateQueryStringFromSearchPageOptions = ( "searchOptions", JSON.stringify( searchPageOptions, - (key: string, value: object[] | undefined) => { - return match( - [ - Literal("collections"), - () => value?.map((collection) => pick(collection, ["uuid"])), - ], - [Literal("owner"), () => (value ? pick(value, ["id"]) : undefined)], - [ - Literal("mimeTypeFilters"), - () => value?.map((filter) => pick(filter, ["id"])), - ], - // As we can get MIME types from filters, we can skip key "mimeTypes". - [Literal("mimeTypes"), () => undefined], - // Skip advancedSearchCriteria as we can build it from `advFieldValue`. - [Literal("advancedSearchCriteria"), () => undefined], - [ - Literal("advFieldValue"), - () => pipe(value, O.fromNullable, O.map(Array.from), O.toUndefined), - ], - [Unknown, () => value ?? undefined] - )(key); - } + (key: string, value: object[] | undefined) => + simpleMatch({ + collections: () => + value?.map((collection) => pick(collection, ["uuid"])), + owner: () => (value ? pick(value, ["id"]) : undefined), + mimeTypeFilters: () => value?.map((filter) => pick(filter, ["id"])), + mimeTypes: () => undefined, // As we can get MIME types from filters, we can skip key "mimeTypes". + advancedSearchCriteria: () => undefined, // Skip advancedSearchCriteria as we can build it from `advFieldValue`. + advFieldValue: () => + pipe(value, O.fromNullable, O.map(Array.from), O.toUndefined), + _: () => value ?? undefined, + })(key) ) ); return params.toString(); @@ -511,7 +481,7 @@ export const newSearchQueryToSearchPageOptions = async ( return value; }); - if (!DehydratedSearchPageOptionsRunTypes.guard(parsedOptions)) { + if (!DehydratedSearchPageOptionsCodec.is(parsedOptions)) { console.warn("Invalid search query params received. Using defaults."); return defaultSearchPageOptions; } @@ -572,7 +542,7 @@ const getDisplayModeFromLegacyParams = ( _: (mode) => { // Because Old UI also uses query string `type` for My resources type and Legacy // My resources page does not have galleries, we always return "list". - if (LegacyMyResourcesRuntypes.guard(mode)) { + if (LegacyMyResourcesCodec.is(mode)) { return "list"; } throw new TypeError(`Unknown Legacy display mode [${mode}]`); @@ -696,8 +666,8 @@ export const legacyQueryStringToSearchPageOptions = async ( const owner = await getOwnerFromLegacyParams(getQueryParam("owner")); const sortOrder = pipe( getQueryParam("sort")?.toLowerCase(), - O.fromPredicate(OEQ.Search.SortOrderRunTypes.guard), - O.getOrElse(() => defaultSearchOptions.sortOrder) + OEQ.Codec.Search.SortOrderCodec.decode, + E.getOrElse(() => defaultSearchOptions.sortOrder) ); const datePrimary = getQueryParam("dp"); @@ -756,7 +726,7 @@ export const RAW_MODE_STORAGE_KEY = "raw_mode"; * Read the value of wildcard mode from LocalStorage. */ export const getRawModeFromStorage = (): boolean => - readDataFromStorage(RAW_MODE_STORAGE_KEY, Boolean.guard) ?? + readDataFromStorage(RAW_MODE_STORAGE_KEY, t.boolean.is) ?? defaultSearchOptions.rawMode; export const writeRawModeToStorage = (value: boolean): void => diff --git a/react-front-end/tsrc/search/components/SearchOrderSelect.tsx b/react-front-end/tsrc/search/components/SearchOrderSelect.tsx index 3633d1f6cc..6545a3af67 100644 --- a/react-front-end/tsrc/search/components/SearchOrderSelect.tsx +++ b/react-front-end/tsrc/search/components/SearchOrderSelect.tsx @@ -16,6 +16,8 @@ * limitations under the License. */ import { InputLabel, MenuItem, Select } from "@mui/material"; +import { pipe } from "fp-ts/function"; +import * as E from "../../util/Either.extended"; import * as React from "react"; import { languageStrings } from "../../util/langstrings"; import * as OEQ from "@openequella/rest-api-client"; @@ -76,7 +78,12 @@ export const SearchOrderSelect = ({ // If sortOrder is undefined, pass an empty string to select nothing. value={value ?? ""} onChange={(event) => - onChange(OEQ.Search.SortOrderRunTypes.check(event.target.value)) + pipe( + event.target.value, + OEQ.Codec.Search.SortOrderCodec.decode, + E.getOrThrow, + onChange + ) } variant="standard" > diff --git a/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx b/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx index bcba80dfe5..83a8b4ba2d 100644 --- a/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx +++ b/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx @@ -17,6 +17,8 @@ */ import { FormControl, MenuItem, OutlinedInput, Select } from "@mui/material"; import { styled } from "@mui/material/styles"; +import { pipe } from "fp-ts/function"; +import * as E from "../../../util/Either.extended"; import * as React from "react"; import { languageStrings } from "../../../util/langstrings"; import * as OEQ from "@openequella/rest-api-client"; @@ -46,9 +48,6 @@ export default function DefaultSortOrderSetting({ const { relevance, lastModified, dateCreated, title, userRating } = languageStrings.settings.searching.searchPageSettings; - const validateSortOrder = (value: unknown): OEQ.Search.SortOrder => - OEQ.Search.SortOrderRunTypes.check(value); - const options: [OEQ.Search.SortOrder, string][] = [ ["rank", relevance], ["datemodified", lastModified], @@ -62,7 +61,14 @@ export default function DefaultSortOrderSetting({