diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a5b45e0a4..e7b30a55c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,4 +19,5 @@ jobs: NODE_ENV: production - uses: ArtiomTr/jest-coverage-report-action@v2 with: + annotations: none test-script: npm run test-coverage diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecf7ec911..232e9c977 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,17 @@ jobs: env: NODE_ENV: production - run: npm test + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 18.x + uses: actions/setup-node@v3 + with: + node-version: "18.x" + - run: npm ci + - run: npm run build + env: + NODE_ENV: production + - run: npm run check diff --git a/package-lock.json b/package-lock.json index 04963aa2c..c9c55e068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "storybook": "7.6.10", "storybook-addon-cookie": "^3.2.0", "storybook-mock-date-decorator": "^1.0.1", + "svelte-check": "^3.6.3", "svelte-jester": "^3.0.0", "tape": "^5.7.2", "ts-jest": "^29.1.2" @@ -20405,6 +20406,28 @@ "node": ">=16" } }, + "node_modules/svelte-check": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.3.tgz", + "integrity": "sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "chokidar": "^3.4.1", + "fast-glob": "^3.2.7", + "import-fresh": "^3.2.1", + "picocolors": "^1.0.0", + "sade": "^1.7.4", + "svelte-preprocess": "^5.1.0", + "typescript": "^5.0.3" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "peerDependencies": { + "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" + } + }, "node_modules/svelte-dev-helper": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/svelte-dev-helper/-/svelte-dev-helper-1.1.9.tgz", @@ -20843,31 +20866,32 @@ "integrity": "sha512-p1RgYhqT/zuVe9Lu8XTIudwk2nNYTLu7V9EFezqmMKjiFcTBMx8WRQRfVNaX5WPXHOGdPXVUYcfsSddCAwFd3Q==" }, "node_modules/svelte-preprocess": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.4.tgz", - "integrity": "sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", + "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", "hasInstallScript": true, "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", - "magic-string": "^0.27.0", + "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 14.10.0" + "node": ">= 16.0.0", + "pnpm": "^8.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0", + "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0", + "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -20919,17 +20943,6 @@ "sass": "^1.35.2" } }, - "node_modules/svelte-preprocess/node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/svelte/node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", diff --git a/package.json b/package.json index a5765a133..723824698 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "storybook": "7.6.10", "storybook-addon-cookie": "^3.2.0", "storybook-mock-date-decorator": "^1.0.1", + "svelte-check": "^3.6.3", "svelte-jester": "^3.0.0", "tape": "^5.7.2", "ts-jest": "^29.1.2" @@ -92,6 +93,7 @@ "build-serve": "cross-env NODE_ENV=production webpack && serve public -l 80 --single", "build-analyze": "cross-env NODE_ENV=production-analyze webpack", "build-storybook": "storybook build", + "check": "svelte-check --threshold error", "dev": "concurrently \"npm:dev-app\" \"npm:dev-embed\"", "dev-app": "cross-env NODE_ENV=development SUPPRESS_WARNINGS=1 webpack serve --config webpack.app.config.js", "dev-embed": "cross-env NODE_ENV=development webpack --config webpack.embed.config.js", diff --git a/src/addons/dispatch/Premium.svelte b/src/addons/dispatch/Premium.svelte index c7829b802..71659e996 100644 --- a/src/addons/dispatch/Premium.svelte +++ b/src/addons/dispatch/Premium.svelte @@ -14,7 +14,7 @@ } from "../../manager/orgsAndUsers.js"; import type { AddOnListItem } from "../types"; - import type { User } from "../../pages/app/accounts/types"; + import { type User, isOrg } from "../../api/types/orgAndUser"; export let addon: AddOnListItem; @@ -23,11 +23,11 @@ let spendingLimitEnabled = false; let spendingLimit = 0; - $: creditBalance = user?.organization + $: creditBalance = isOrg(user?.organization) ? getCreditBalance(user.organization) : 0; $: isIndividualOrg = - typeof user?.organization !== "string" && user?.organization?.individual; + isOrg(user?.organization) && user?.organization?.individual; $: isPremium = addon?.parameters?.categories?.includes("premium") ?? false; const { amount, unit, price } = addon?.parameters?.cost ?? {}; diff --git a/src/addons/dispatch/fields/ArrayField.svelte b/src/addons/dispatch/fields/ArrayField.svelte index db3f24472..797e4c50a 100644 --- a/src/addons/dispatch/fields/ArrayField.svelte +++ b/src/addons/dispatch/fields/ArrayField.svelte @@ -17,7 +17,7 @@ }; export let count: number = 1; - export let value: [any] = Array(count).fill(null); + export let value: any[] = Array(count).fill(null); $: numItems = value?.length ?? 0; diff --git a/src/api/orgAndUser.js b/src/api/orgAndUser.ts similarity index 63% rename from src/api/orgAndUser.js rename to src/api/orgAndUser.ts index 811621066..918f07111 100644 --- a/src/api/orgAndUser.js +++ b/src/api/orgAndUser.ts @@ -1,10 +1,11 @@ import session, { cookiesEnabled, getCsrfToken } from "./session.js"; import { USER_EXPAND, ORG_EXPAND, DEFAULT_EXPAND } from "./common.js"; -import { queryBuilder } from "@/util/url.js"; -import { grabAllPages } from "@/util/paginate.js"; +import { queryBuilder } from "../util/url.js"; +import { grabAllPages } from "../util/paginate.js"; import { apiUrl } from "./base.js"; +import { Nullable, Org, User } from "./types"; -export async function getMe(expand = DEFAULT_EXPAND) { +export async function getMe(expand = DEFAULT_EXPAND): Promise> { // Check that the user is logged in via cookies if (cookiesEnabled && !getCsrfToken()) return null; // Check that the user is logged in via network request @@ -15,49 +16,64 @@ export async function getMe(expand = DEFAULT_EXPAND) { return data; } -export async function getUser(id, expand = DEFAULT_EXPAND) { +export async function getUser( + id: number, + expand = DEFAULT_EXPAND, +): Promise { const { data } = await session.get( queryBuilder(apiUrl(`users/${id}/`), { expand }), ); return data; } -export async function changeActiveOrg(orgId) { +export async function changeActiveOrg(orgId: number): Promise { await session.patch(apiUrl(`users/me/`), { organization: orgId }); } -export async function getOrganization(id, expand = ORG_EXPAND) { +export async function getOrganization( + id: number, + expand = ORG_EXPAND, +): Promise { const { data } = await session.get( queryBuilder(apiUrl(`organizations/${id}/`), { expand }), ); return data; } -export async function getOrganizations(individual = null, expand = ORG_EXPAND) { +export async function getOrganizations( + individual = null, + expand = ORG_EXPAND, +): Promise { const orgs = await grabAllPages( queryBuilder(apiUrl(`organizations/`), { individual, expand }), ); return orgs; } -export async function getOrganizationsByIds(ids, expand = ORG_EXPAND) { +export async function getOrganizationsByIds( + ids: number[], + expand = ORG_EXPAND, +): Promise { const orgs = await grabAllPages( queryBuilder(apiUrl(`organizations/`), { id__in: ids, expand }), ); return orgs; } -export async function getUsers({ projectIds, orgIds }, expand = USER_EXPAND) { +export async function getUsers( + { projectIds, orgIds }: { projectIds: number[]; orgIds: number[] }, + expand = USER_EXPAND, +): Promise { if (projectIds != null && orgIds != null) { throw new Error("Only specify one of project or org ids"); } const query = { expand }; if (projectIds != null) { - query["project"] = projectIds.map((id) => `${id}`).join(","); + query["project"] = projectIds.map(String).join(","); } if (orgIds != null) { - query["organization"] = orgIds.map((id) => `${id}`).join(","); + query["organization"] = orgIds.map(String).join(","); } const users = await grabAllPages(queryBuilder(apiUrl(`users/`), query)); @@ -67,7 +83,7 @@ export async function getUsers({ projectIds, orgIds }, expand = USER_EXPAND) { export async function autocompleteOrganizations( prefix = "", individual = false, -) { +): Promise { const { data } = await session.get( queryBuilder(apiUrl("organizations/"), { name__istartswith: prefix, @@ -78,7 +94,10 @@ export async function autocompleteOrganizations( return data.results; } -export async function autocompleteUsers(prefix = "", orgIds = null) { +export async function autocompleteUsers( + prefix = "", + orgIds = null, +): Promise { const { data } = await session.get( queryBuilder(apiUrl("users/"), { name__istartswith: prefix, @@ -89,11 +108,11 @@ export async function autocompleteUsers(prefix = "", orgIds = null) { return data.results; } -export async function createMailkey() { +export async function createMailkey(): Promise { const { data } = await session.post(apiUrl("users/mailkey/")); return data.mailkey; } -export async function destroyMailkey() { +export async function destroyMailkey(): Promise { await session.delete(apiUrl("users/mailkey/")); } diff --git a/src/api/project.js b/src/api/project.ts similarity index 55% rename from src/api/project.js rename to src/api/project.ts index 25916039b..e1a6397a1 100644 --- a/src/api/project.js +++ b/src/api/project.ts @@ -4,37 +4,42 @@ import session from "./session.js"; import { apiUrl } from "./base.js"; -import { Project } from "@/structure/project.js"; -import { grabAllPages } from "@/util/paginate.js"; +import { grabAllPages } from "../util/paginate.js"; import { DEFAULT_ORDERING, DEFAULT_EXPAND } from "./common.js"; -import { queryBuilder } from "@/util/url.js"; -import { Results } from "@/structure/results.js"; -import { Document } from "@/structure/document.js"; +import { queryBuilder } from "../util/url.js"; +import type { Page, Project, User, Document, DocumentAccess } from "./types"; -export async function newProject(title, description) { - // Create a project +// Create a project +export async function newProject(title, description): Promise { const { data } = await session.post(apiUrl("projects/"), { title, description, }); - return new Project(data); + return data; } -export async function deleteProject(projectId) { +export async function deleteProject(projectId: number): Promise { // Delete the project with the specified id await session.delete(apiUrl(`projects/${projectId}/`)); } -export async function updateProject(projectId, title, description) { - // Update a project +// Update a project +export async function updateProject( + projectId: number, + title: string, + description: string, +): Promise { const { data } = await session.patch(apiUrl(`projects/${projectId}/`), { title, description, }); - return new Project(data); + return data; } -export async function addDocumentsToProject(projectId, docIds) { +export async function addDocumentsToProject( + projectId: number, + docIds: number[], +): Promise { await session.post( apiUrl(`projects/${projectId}/documents/`), docIds.map((id) => ({ @@ -43,25 +48,34 @@ export async function addDocumentsToProject(projectId, docIds) { ); } -export async function removeDocumentsFromProject(projectId, docIds) { +export async function removeDocumentsFromProject( + projectId: number, + docIds: number[], +): Promise { await session.delete( apiUrl( `projects/${projectId}/documents/?document_id__in=${docIds - .map((x) => `${x}`) + .map(String) .join(",")}`, ), ); } -export async function getProjects(userId, expand = DEFAULT_EXPAND) { +export async function getProjects( + userId: number, + expand: string = DEFAULT_EXPAND, +): Promise { // Returns all projects const projects = await grabAllPages( queryBuilder(apiUrl("projects/"), { user: userId, expand }), ); - return projects.map((project) => new Project(project)); + return projects; } -export async function getProjectUsers(projectId, expand = DEFAULT_EXPAND) { +export async function getProjectUsers( + projectId: number, + expand: string = DEFAULT_EXPAND, +): Promise { const users = await grabAllPages( apiUrl(queryBuilder(`projects/${projectId}/users/`, { expand })), ); @@ -69,27 +83,24 @@ export async function getProjectUsers(projectId, expand = DEFAULT_EXPAND) { } export async function getProjectDocuments( - projectId, - extraParams = {}, - ordering = DEFAULT_ORDERING, - expand = DEFAULT_EXPAND + ",document", -) { + projectId: number, + extraParams: Record = {}, + ordering: string = DEFAULT_ORDERING, + expand: string = DEFAULT_EXPAND + ",document", +): Promise<[string, Page]> { // Return documents with the specified parameters const params = { ...extraParams, ordering, expand, version: "2.0" }; const url = apiUrl(queryBuilder(`projects/${projectId}/documents/`, params)); const { data } = await session.get(url); - data.results = data.results.map( - (document) => new Document(document.document), - ); - return new Results(url, data); + return [url, data]; } export async function addUserToProject( - projectId, - email, - access, - expand = DEFAULT_EXPAND, -) { + projectId: number, + email: string, + access: DocumentAccess, + expand: string = DEFAULT_EXPAND, +): Promise> { const { data: user } = await session.post( apiUrl(`projects/${projectId}/users/`), { @@ -106,12 +117,19 @@ export async function addUserToProject( return data; } -export async function updateUserAccess(projectId, userId, access) { +export async function updateUserAccess( + projectId: number, + userId: string, + access: DocumentAccess, +): Promise { await session.patch(apiUrl(`projects/${projectId}/users/${userId}/`), { access, }); } -export async function removeUser(projectId, userId) { +export async function removeUser( + projectId: number, + userId: string, +): Promise { await session.delete(apiUrl(`projects/${projectId}/users/${userId}/`)); } diff --git a/src/api/session.js b/src/api/session.js index fc6267ebd..cf7e7837f 100644 --- a/src/api/session.js +++ b/src/api/session.js @@ -20,11 +20,12 @@ try { export function getCsrfToken() { if (typeof window === "undefined" || typeof document === "undefined") return; - - const [key, token] = document.cookie - .split(";") - .map((c) => c.split("=")) - .find(([k, v]) => k === CSRF_COOKIE_NAME); + const [key, token] = + document.cookie + ?.split(";") + ?.map((c) => c.split("=")) + // in case there's spaces in the cookie string, trim the key + ?.find(([k, v]) => k.trim() === CSRF_COOKIE_NAME) ?? []; return token; } diff --git a/src/api/test/fixtures/empty.ts b/src/api/test/fixtures/empty.ts index bae10553d..3842ca970 100644 --- a/src/api/test/fixtures/empty.ts +++ b/src/api/test/fixtures/empty.ts @@ -1,6 +1,6 @@ import type { Page } from "../../types/common"; -export const emptyList: Page = { +export const emptyList: Page = { count: 0, next: null, previous: null, diff --git a/src/api/types/common.d.ts b/src/api/types/common.ts similarity index 100% rename from src/api/types/common.d.ts rename to src/api/types/common.ts diff --git a/src/api/types/document.ts b/src/api/types/document.ts new file mode 100644 index 000000000..33283c5c1 --- /dev/null +++ b/src/api/types/document.ts @@ -0,0 +1,43 @@ +import { Nullable } from "./common"; +import { Project } from "./project"; + +export type DocumentAccess = "public" | "organization" | "private"; + +export interface DocumentRevision { + version: number; + user: number; + created_at: string; + comment: string; + url: string; +} + +export interface Document { + id: number; + access: DocumentAccess; + admin_noindex: boolean; + asset_url: string; + canonical_url: string; + created_at: string; + data: Record; + description: string; + edit_access: boolean; + file_hash: string; + noindex: boolean; + language: string; + organization: number; + original_extension: string; + page_count: number; + page_spec: string; + projects: Project[]; + publish_at: Nullable; + published_url: string; + related_article: string; + revision_control: boolean; + revisions?: DocumentRevision[]; + slug: string; + source: string; + status: "success" | "failure" | "queued" | "in_progress"; + title: string; + updated_at: string; + user: number; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts new file mode 100644 index 000000000..3748132ee --- /dev/null +++ b/src/api/types/index.ts @@ -0,0 +1,4 @@ +export { Page, Nullable, Maybe } from "./common"; +export { User, Org, isOrg } from "./orgAndUser"; +export { Project } from "./project"; +export { Document, DocumentRevision, DocumentAccess } from "./document"; diff --git a/src/api/types/orgAndUser.d.ts b/src/api/types/orgAndUser.ts similarity index 72% rename from src/api/types/orgAndUser.d.ts rename to src/api/types/orgAndUser.ts index 0b293f129..9ed9b1623 100644 --- a/src/api/types/orgAndUser.d.ts +++ b/src/api/types/orgAndUser.ts @@ -8,7 +8,7 @@ interface PremiumOrgFields { } export interface Org extends Partial { - id: string; + id: number; name: string; slug: string; avatar_url: string; @@ -27,11 +27,15 @@ export interface IndividualOrg extends Org { } export interface User { - id: string; + id: number; name: Maybe; avatar_url: Maybe; username: string; - organization: string | Org; - organizations: string[]; - admin_organizations: string[]; + organization: number | Org; + organizations: number[]; + admin_organizations: number[]; +} + +export function isOrg(org?: null | number | Org): org is Org { + return org !== undefined && org !== null && typeof org !== "number"; } diff --git a/src/api/types/project.d.ts b/src/api/types/project.ts similarity index 78% rename from src/api/types/project.d.ts rename to src/api/types/project.ts index 9ce379603..49b0a6628 100644 --- a/src/api/types/project.d.ts +++ b/src/api/types/project.ts @@ -1,12 +1,14 @@ export interface Project { id: number; + user: number; slug: string; title: string; - user: number; - created_at: string; description: string; - add_remove_access: boolean; private: boolean; + created_at: string; updated_at: string; edit_access: boolean; + add_remove_access: boolean; + // TODO: Add 'pinned' field on the server + pinned?: boolean; } diff --git a/src/common/UploadOptions.svelte b/src/common/UploadOptions.svelte index 5f0f06a79..c3563a68e 100644 --- a/src/common/UploadOptions.svelte +++ b/src/common/UploadOptions.svelte @@ -6,8 +6,8 @@ } from "../api/languages.js"; import { _ } from "svelte-i18n"; import Select from "./Select.svelte"; - import type { Org, User } from "../pages/app/accounts/types.js"; - import { getMe, getOrganization } from "../api/orgAndUser.js"; + import { isOrg, type Org, type User } from "../api/types/orgAndUser"; + import { getMe, getOrganization } from "../api/orgAndUser"; import { onMount } from "svelte"; import { getUpgradeUrl, @@ -35,7 +35,7 @@ } try { let activeOrg = user?.organization; - if (typeof activeOrg === "string") { + if (!isOrg(activeOrg)) { org = (await getOrganization(activeOrg)) as Org; } else { org = activeOrg; diff --git a/src/common/dialog/OwnerDialog.svelte b/src/common/dialog/OwnerDialog.svelte index 700581955..293f3c5e2 100644 --- a/src/common/dialog/OwnerDialog.svelte +++ b/src/common/dialog/OwnerDialog.svelte @@ -8,7 +8,7 @@ import { layout } from "@/manager/layout.js"; import { viewer } from "@/viewer/viewer.js"; - import { autocompleteUsers } from "@/api/orgAndUser.js"; + import { autocompleteUsers } from "@/api/orgAndUser.ts"; import { orgsAndUsers } from "@/manager/orgsAndUsers.js"; import { changeOwnerForDocuments } from "@/manager/documents.js"; import { sameProp } from "@/util/array.js"; diff --git a/src/common/dialog/RevisionsDialog.svelte b/src/common/dialog/RevisionsDialog.svelte index d075deb75..7cafc2b04 100644 --- a/src/common/dialog/RevisionsDialog.svelte +++ b/src/common/dialog/RevisionsDialog.svelte @@ -1,7 +1,7 @@