diff --git a/applications/browser-extension/end-to-end-tests/tests/pageEditor/saveMod.spec.ts b/applications/browser-extension/end-to-end-tests/tests/pageEditor/saveMod.spec.ts index b4a5dc4133..318b034317 100644 --- a/applications/browser-extension/end-to-end-tests/tests/pageEditor/saveMod.spec.ts +++ b/applications/browser-extension/end-to-end-tests/tests/pageEditor/saveMod.spec.ts @@ -52,7 +52,6 @@ test("can save a new trigger mod", async ({ test("#9349: can save new mod with multiple components", async ({ page, - extensionId, newPageEditorPage, }) => { await page.goto("/"); diff --git a/applications/browser-extension/package.json b/applications/browser-extension/package.json index 0e6f848888..934418c13c 100644 --- a/applications/browser-extension/package.json +++ b/applications/browser-extension/package.json @@ -47,6 +47,7 @@ "@rjsf/core": "^5.22.3", "@rjsf/utils": "^5.22.3", "@szhsin/react-menu": "^4.2.2", + "@tiptap/extension-image": "^2.9.1", "@tiptap/extension-link": "^2.9.1", "@tiptap/extension-underline": "^2.9.1", "@tiptap/pm": "^2.9.1", @@ -105,7 +106,7 @@ "kbar": "^0.1.0-beta.45", "lodash-es": "^4.17.21", "mark.js": "^8.11.1", - "marked": "^14.1.3", + "marked": "^15.0.0", "memoize-one": "^6.0.0", "mustache": "^4.2.0", "nunjucks": "^3.2.4", diff --git a/applications/browser-extension/public/mockServiceWorker.js b/applications/browser-extension/public/mockServiceWorker.js index d113594449..1f45c4cf81 100644 --- a/applications/browser-extension/public/mockServiceWorker.js +++ b/applications/browser-extension/public/mockServiceWorker.js @@ -2,7 +2,7 @@ /* tslint:disable */ /** - * Mock Service Worker (1.3.4). + * Mock Service Worker (1.3.5). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. diff --git a/applications/browser-extension/src/components/IsolatedComponent.tsx b/applications/browser-extension/src/components/IsolatedComponent.tsx index d573a3e523..7eced7cd88 100644 --- a/applications/browser-extension/src/components/IsolatedComponent.tsx +++ b/applications/browser-extension/src/components/IsolatedComponent.tsx @@ -23,6 +23,9 @@ import React, { Suspense, useMemo } from "react"; import { Stylesheets } from "@/components/Stylesheets"; import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import isolatedComponentList from "./isolatedComponentList"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { appApi } from "@/data/service/api"; const MODE = process.env.SHADOW_DOM as "open" | "closed"; @@ -110,6 +113,17 @@ type Props = React.DetailedHTMLProps< noStyle?: boolean; }; +const store = configureStore({ + reducer: { + [appApi.reducerPath]: appApi.reducer, + }, + middleware(getDefaultMiddleware) { + /* eslint-disable unicorn/prefer-spread -- It's not Array#concat, can't use spread */ + return getDefaultMiddleware().concat(appApi.middleware); + /* eslint-enable unicorn/prefer-spread */ + }, +}); + /** * Isolate component loaded via React.lazy() in a shadow DOM, including its styles. * @@ -151,8 +165,10 @@ export default function IsolatedComponent({ - {/* Must call the factory on each render to pick up changes to the component props */} - {factory(LazyComponent)} + + {/* Must call the factory on each render to pick up changes to the component props */} + {factory(LazyComponent)} + ); diff --git a/applications/browser-extension/src/components/formBuilder/edit/ActiveField.tsx b/applications/browser-extension/src/components/formBuilder/edit/ActiveField.tsx index 81cbf8fd73..1c66c76161 100644 --- a/applications/browser-extension/src/components/formBuilder/edit/ActiveField.tsx +++ b/applications/browser-extension/src/components/formBuilder/edit/ActiveField.tsx @@ -3,7 +3,6 @@ import { type SelectStringOption, type SetActiveField, } from "@/components/formBuilder/formBuilderTypes"; -import FieldEditor from "./fieldEditor/FieldEditor"; import { moveStringInArray, getNormalizedUiOrder, @@ -13,6 +12,7 @@ import FieldTemplate from "@/components/form/FieldTemplate"; import LayoutWidget from "@/components/LayoutWidget"; import { findLast } from "lodash"; import { type FormikErrors } from "formik"; +import FieldEditor from "@/components/formBuilder/edit/fieldEditor/FieldEditor"; export const ActiveField: React.FC<{ name: string; diff --git a/applications/browser-extension/src/components/formBuilder/edit/fieldEditor/RichTextFields.tsx b/applications/browser-extension/src/components/formBuilder/edit/fieldEditor/RichTextFields.tsx index c28e31c858..1e7c895dc7 100644 --- a/applications/browser-extension/src/components/formBuilder/edit/fieldEditor/RichTextFields.tsx +++ b/applications/browser-extension/src/components/formBuilder/edit/fieldEditor/RichTextFields.tsx @@ -28,13 +28,11 @@ const RichTextFields: React.FunctionComponent<{ uiOptionsPath: string }> = ({ return ( = ({ propertySchema.oneOf = [{ const: "" }]; } } + + if (value[UI_WIDGET] === "richText") { + propertySchema.readOnly = true; + } } }), [rjsfSchema, activeField], diff --git a/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.test.tsx b/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.test.tsx index 3f095c714a..d88ecb080e 100644 --- a/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.test.tsx +++ b/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.test.tsx @@ -22,14 +22,24 @@ import CustomFormComponent, { type CustomFormComponentProps, } from "@/bricks/renderers/CustomFormComponent"; import { type Schema } from "@/types/schemaTypes"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { appApi } from "@/data/service/api"; describe("RichTextWidget", () => { const user = userEvent.setup({ - // 20ms delay between key presses to allow the editor state to update - // before the next key press delay: 20, }); + const createTestStore = () => + configureStore({ + reducer: { + appApi: appApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(appApi.middleware), + }); + const createSchema = (properties: Record): Schema => ({ type: "object", properties, @@ -48,15 +58,19 @@ describe("RichTextWidget", () => { }: Pick & { onSubmit?: jest.Mock; }) => { + const store = createTestStore(); + render( - , + + + , ); return { onSubmit }; }; diff --git a/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.tsx b/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.tsx index 9e96277ba0..bd535147f8 100644 --- a/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.tsx +++ b/applications/browser-extension/src/components/formBuilder/widgets/RichTextWidget.tsx @@ -16,8 +16,9 @@ */ import React from "react"; -import { type ErrorSchema, type WidgetProps } from "@rjsf/utils"; +import { type WidgetProps } from "@rjsf/utils"; import RichTextEditor from "@/components/richTextEditor/RichTextEditor"; +import { validateUUID } from "@/types/helpers"; const RichTextWidget: React.FunctionComponent = ({ id, @@ -27,19 +28,9 @@ const RichTextWidget: React.FunctionComponent = ({ disabled, readonly, options, - value, }) => { const { database } = options; - if (!database) { - // TODO: Can't figure out how to satisfy this type without casting, but this is how it's done in the docs - // https://rjsf-team.github.io/react-jsonschema-form/docs/advanced-customization/custom-widgets-fields/#raising-errors-from-within-a-custom-widget-or-field - const databaseConfigurationError = { - __errors: ["Rich text field asset database is required"], - } as ErrorSchema; - onChange(value, databaseConfigurationError); - } - return ( { @@ -53,6 +44,7 @@ const RichTextWidget: React.FunctionComponent = ({ onBlur(id, editor.getHTML()); }} editable={!(disabled || readonly)} + assetDatabaseId={validateUUID(database)} /> ); }; diff --git a/applications/browser-extension/src/components/richTextEditor/ErrorContext.tsx b/applications/browser-extension/src/components/richTextEditor/ErrorContext.tsx new file mode 100644 index 0000000000..4fcf4f56be --- /dev/null +++ b/applications/browser-extension/src/components/richTextEditor/ErrorContext.tsx @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createContext, useContext } from "react"; + +type ErrorContextType = { + error: string | null; + setError: (error: string | null) => void; +}; + +const ErrorContext = createContext(null); + +export function useShowError() { + const context = useContext(ErrorContext); + if (!context) { + throw new Error("useRichTextError must be used within a RichTextEditor"); + } + + return context; +} + +export default ErrorContext; diff --git a/applications/browser-extension/src/components/richTextEditor/ErrorToast.tsx b/applications/browser-extension/src/components/richTextEditor/ErrorToast.tsx new file mode 100644 index 0000000000..565278445f --- /dev/null +++ b/applications/browser-extension/src/components/richTextEditor/ErrorToast.tsx @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +import { Toast, Button } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExclamationCircle, faTimes } from "@fortawesome/free-solid-svg-icons"; +import styles from "./RichTextEditor.module.scss"; + +interface ErrorToastProps { + error: string | null; + onClose: () => void; +} + +const ErrorToast: React.FC = ({ error, onClose }) => ( + + + {error} + + + +); + +export default ErrorToast; diff --git a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.module.scss b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.module.scss index fc0d97c30d..386fa69221 100644 --- a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.module.scss +++ b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.module.scss @@ -23,6 +23,7 @@ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + position: relative; &:focus-within { border-color: #80bdff; @@ -41,3 +42,34 @@ outline: 0; } } + +.error { + color: #dc3545; + padding: 4px 8px; + font-size: 0.875rem; + position: absolute; + bottom: 4px; + left: 4px; + + span { + display: flex; + align-items: center; + } + + :global(.btn) { + margin-left: 8px; + color: #dc3545; + padding: 0; + border: none; + background-color: transparent; + + &:focus, + &:active, + &:hover, + &:active:focus { + color: #dc3545; + background-color: transparent; + box-shadow: none !important; + } + } +} diff --git a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.test.tsx b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.test.tsx index 47599b41bb..c0160117b6 100644 --- a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.test.tsx +++ b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.test.tsx @@ -1,7 +1,42 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import RichTextEditor from "./RichTextEditor"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { appApi } from "@/data/service/api"; +import { API_PATHS } from "@/data/service/urlPaths"; +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import { uuidv4 } from "@/types/helpers"; + +const axiosMock = new MockAdapter(axios); + +const createTestStore = () => + configureStore({ + reducer: { + appApi: appApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(appApi.middleware), + }); + +const customRender = (ui: React.ReactElement, options = {}) => + render(ui, { wrapper: ({ children }: { children: React.ReactNode }) => ({children} + ), ...options }); + +jest.mock( + "@/components/richTextEditor/toolbar/ImageButton/useFilePicker", + () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + async pickFile() { + return new File(["test"], "test.png", { type: "image/png" }); + }, + isFilePickerOpen: false, + })), + }), +); describe("RichTextEditor", () => { const user = userEvent.setup({ @@ -9,7 +44,7 @@ describe("RichTextEditor", () => { }); test("applies bold formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "regular"); @@ -20,7 +55,7 @@ describe("RichTextEditor", () => { }); test("applies italic formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "regular"); @@ -31,7 +66,7 @@ describe("RichTextEditor", () => { }); test("applies underline formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "regular"); @@ -47,7 +82,7 @@ describe("RichTextEditor", () => { }); test("applies bold, italic and underline formatting using toolbar buttons", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "regular"); @@ -70,7 +105,7 @@ describe("RichTextEditor", () => { test.each([1, 2, 3, 4, 5, 6])( "applies heading level %i using dropdown", async (level) => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.tripleClick(editor); // Select all text @@ -89,7 +124,7 @@ describe("RichTextEditor", () => { ); test("converts heading back to paragraph text", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.tripleClick(editor); // Select all text @@ -105,10 +140,15 @@ describe("RichTextEditor", () => { describe("lists", () => { test("applies bullet list formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "first item"); + + // Open the overflow menu first + await user.click( + screen.getByRole("button", { name: "More editing options" }), + ); await user.click(screen.getByRole("button", { name: "Bullet List" })); expect(editor?.innerHTML).toBe("
  • first item

"); @@ -120,10 +160,15 @@ describe("RichTextEditor", () => { }); test("applies numbered list formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "first item"); + + // Open the overflow menu first + await user.click( + screen.getByRole("button", { name: "More editing options" }), + ); await user.click(screen.getByRole("button", { name: "Numbered List" })); expect(editor?.innerHTML).toBe("
  1. first item

"); @@ -135,11 +180,15 @@ describe("RichTextEditor", () => { }); test("can remove list formatting", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "list item"); + // Open the overflow menu first + await user.click( + screen.getByRole("button", { name: "More editing options" }), + ); await user.click(screen.getByRole("button", { name: "Bullet List" })); expect(editor?.innerHTML).toBe("
  • list item

"); @@ -149,7 +198,7 @@ describe("RichTextEditor", () => { }); test("applies strikethrough formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "regular"); @@ -166,7 +215,7 @@ describe("RichTextEditor", () => { // User.pointer is successfully selecting the test, but Tiptap isn't seeing it // eslint-disable-next-line jest/no-disabled-tests -- TODO: playwright test for this when RichTextEditor is complete test.skip("applies link formatting using toolbar button", async () => { - render(); + customRender(); const editor = screen.getByRole("textbox"); await user.type(editor, "This is my link"); @@ -188,4 +237,47 @@ describe("RichTextEditor", () => { screen.getByRole("textbox", { name: /newurl/i }), ).toBeInTheDocument(); }); + + test("uploads and inserts image using toolbar button", async () => { + const databaseId = uuidv4(); + const assetId = uuidv4(); + + const file = new File(["test"], "test.png", { type: "image/png" }); + const mockDownloadUrl = new URL("https://example.com/test.png"); + const mockUploadUrl = new URL("https://storage-upload.example.com"); + + axiosMock.onPost(API_PATHS.ASSET_PRE_UPLOAD(databaseId)).reply(200, { + asset: { + id: assetId, + download_url: mockDownloadUrl.href, + filename: file.name, + is_uploaded: false, + updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + }, + upload_url: mockUploadUrl.href, + fields: { + key: "test-key", + policy: "test-policy", + }, + }).onPost(mockUploadUrl.href).reply(200).onPatch(API_PATHS.ASSET(databaseId, assetId)).reply(200, { + id: assetId, + download_url: mockDownloadUrl.href, + filename: file.name, + is_uploaded: true, + updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + }); + + customRender(); + + await user.click(screen.getByRole("button", { name: "Insert Image" })); + + await waitFor(() => { + const editor = screen.getByRole("textbox"); + expect(editor?.innerHTML).toBe( + `


`, + ); + }); + }); }); diff --git a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.tsx b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.tsx index 86796e7b10..867cfaa247 100644 --- a/applications/browser-extension/src/components/richTextEditor/RichTextEditor.tsx +++ b/applications/browser-extension/src/components/richTextEditor/RichTextEditor.tsx @@ -20,23 +20,74 @@ import { EditorProvider, type EditorProviderProps } from "@tiptap/react"; import { StarterKit } from "@tiptap/starter-kit"; import { Underline } from "@tiptap/extension-underline"; import { Link } from "@tiptap/extension-link"; -import React from "react"; +import { Image, type ImageOptions } from "@tiptap/extension-image"; +import React, { useState } from "react"; import Toolbar from "@/components/richTextEditor/toolbar/Toolbar"; +import { type UUID } from "@/types/stringTypes"; +import ErrorContext from "@/components/richTextEditor/ErrorContext"; +import ErrorToast from "@/components/richTextEditor/ErrorToast"; -const RichTextEditor: React.FunctionComponent = ( - props: EditorProviderProps, -) => ( -
- } - {...props} - /> -
-); +type EditorProps = EditorProviderProps & { + // A PixieBrix asset database ID to use for uploading images. If not included, the image extension will be disabled. + assetDatabaseId?: UUID; +}; + +interface ImageWithAssetDatabaseOptions extends ImageOptions { + assetDatabaseId: UUID | null; +} + +const CONFIGURED_EXTENSIONS = { + starterKit: StarterKit, + underline: Underline, + link: Link.extend({ inclusive: false }).configure({ openOnClick: false }), + image(assetDatabaseId: UUID) { + return Image.extend({ + addOptions() { + return { + ...this.parent?.(), + assetDatabaseId: null, + }; + }, + }).configure({ + assetDatabaseId, + inline: true, + HTMLAttributes: { style: "max-width: 100%" }, + }); + }, +}; + +const RichTextEditor: React.FunctionComponent = ({ + assetDatabaseId, + ...props +}: EditorProps) => { + const [error, setError] = useState(null); + + return ( + +
+ } + slotAfter={ + { + setError(null); + }} + /> + } + {...props} + /> +
+
+ ); +}; export default RichTextEditor; diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/HeadingLevelDropdown.tsx b/applications/browser-extension/src/components/richTextEditor/toolbar/HeadingLevelDropdown.tsx index 19d7f3dae8..64adae9927 100644 --- a/applications/browser-extension/src/components/richTextEditor/toolbar/HeadingLevelDropdown.tsx +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/HeadingLevelDropdown.tsx @@ -62,6 +62,7 @@ const HeadingLevelDropdown: React.FunctionComponent = () => { aria-label="Heading Level" value={currentLevelOption} onChange={handleChange} + isDisabled={!editor.isEditable} maxMenuHeight={100} classNamePrefix="heading-level" /> diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/index.tsx b/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/index.tsx new file mode 100644 index 0000000000..93b5629840 --- /dev/null +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +import { type Editor, useCurrentEditor } from "@tiptap/react"; +import { Button } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faImage } from "@fortawesome/free-solid-svg-icons"; +import { assertNotNullish } from "@/utils/nullishUtils"; +import { validateUUID } from "@/types/helpers"; +import { useShowError } from "@/components/richTextEditor/ErrorContext"; +import { useUploadAssetMutation } from "@/data/service/api"; +import useFilePicker from "@/components/richTextEditor/toolbar/ImageButton/useFilePicker"; + +const getAssetDatabaseId = (editor: Editor) => { + const imageExtension = editor.options.extensions.find( + (extension) => extension.name === "image", + ); + + if (!imageExtension?.options.assetDatabaseId) { + return null; + } + + return validateUUID(imageExtension.options.assetDatabaseId); +}; + +const ImageButton: React.FunctionComponent = () => { + const [uploadAsset] = useUploadAssetMutation(); + const { editor } = useCurrentEditor(); + const { setError } = useShowError(); + const { pickFile, isFilePickerOpen } = useFilePicker({ + accept: "image/png, image/jpeg, image/gif", + }); + + assertNotNullish( + editor, + "ImageButton must be used within a TipTap editor context", + ); + + const assetDatabaseId = getAssetDatabaseId(editor); + if (!assetDatabaseId) { + return null; + } + + const pickAndInsertImage = async () => { + const file = await pickFile(); + try { + const downloadUrl = await uploadAsset({ + databaseId: assetDatabaseId, + file, + }).unwrap(); + + editor + .chain() + .focus() + .setImage({ src: downloadUrl.href, alt: "" }) + .run(); + } catch (error) { + setError("Failed to upload image, try again"); + reportError( + new Error("Failed to upload image asset", { + cause: error, + }), + ); + } + }; + + return ( + + ); +}; + +export default ImageButton; diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/useFilePicker.ts b/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/useFilePicker.ts new file mode 100644 index 0000000000..c9e0129b12 --- /dev/null +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/ImageButton/useFilePicker.ts @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useState } from "react"; + +interface UseFilePickerOptions { + accept: string; +} + +function useFilePicker({ accept }: UseFilePickerOptions) { + const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); + + const pickFile = async (): Promise => { + if (isFilePickerOpen) { + throw new Error("File picker is already open"); + } + + return new Promise((resolve, reject) => { + setIsFilePickerOpen(true); + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + + const handleFileSelection = (event: Event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + setIsFilePickerOpen(false); + + if (!file) { + reject(new Error("No file selected")); + return; + } + + resolve(file); + }; + + const handleCancel = () => { + setIsFilePickerOpen(false); + reject(new Error("File selection cancelled")); + }; + + input.addEventListener("change", handleFileSelection); + input.addEventListener("cancel", handleCancel); + + input.click(); + input.remove(); + }); + }; + + return { pickFile, isFilePickerOpen }; +} + +export default useFilePicker; diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/LinkButton/index.tsx b/applications/browser-extension/src/components/richTextEditor/toolbar/LinkButton/index.tsx index cf1c51eec6..3d6ae163c0 100644 --- a/applications/browser-extension/src/components/richTextEditor/toolbar/LinkButton/index.tsx +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/LinkButton/index.tsx @@ -83,6 +83,7 @@ const LinkButton: React.FunctionComponent = () => { variant="default" active={editor.isActive("link")} aria-label="Link" + disabled={!editor.isEditable} onClick={() => { if (editor.state.selection.empty && !showPopover) { return; diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.module.scss b/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.module.scss index 692b63e64a..a94473de9b 100644 --- a/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.module.scss +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.module.scss @@ -39,16 +39,16 @@ border: none; box-shadow: none; } + + :global(.heading-level__control--is-disabled) { + background-color: transparent; + } } .toolbar { border-bottom: 1px solid #ced4da; display: flex; - justify-content: space-between; - - @media (min-width: 400px) { - justify-content: flex-start; - } + justify-content: start; @extend .toolbarButtons; } diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.tsx b/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.tsx index f1ac743572..7d4c3d9967 100644 --- a/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.tsx +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/Toolbar.tsx @@ -21,12 +21,11 @@ import styles from "@/components/richTextEditor/toolbar/Toolbar.module.scss"; import HeadingLevelDropdown from "@/components/richTextEditor/toolbar/HeadingLevelDropdown"; import BoldButton from "@/components/richTextEditor/toolbar/BoldButton"; import ItalicButton from "@/components/richTextEditor/toolbar/ItalicButton"; -import BulletedListButton from "@/components/richTextEditor/toolbar/BulletedListButton"; -import NumberedListButton from "@/components/richTextEditor/toolbar/NumberedListButton"; // Required for font-awesome styles to be available in IsolatedComponents import "@fortawesome/fontawesome-svg-core/styles.css"; import LinkButton from "@/components/richTextEditor/toolbar/LinkButton"; import ToolbarOverflow from "@/components/richTextEditor/toolbar/ToolbarOverflow"; +import ImageButton from "@/components/richTextEditor/toolbar/ImageButton" const Toolbar: React.FunctionComponent = () => ( ( - - - - - + diff --git a/applications/browser-extension/src/components/richTextEditor/toolbar/ToolbarOverflow.tsx b/applications/browser-extension/src/components/richTextEditor/toolbar/ToolbarOverflow.tsx index 1b7b2d6303..f281a0231f 100644 --- a/applications/browser-extension/src/components/richTextEditor/toolbar/ToolbarOverflow.tsx +++ b/applications/browser-extension/src/components/richTextEditor/toolbar/ToolbarOverflow.tsx @@ -24,11 +24,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button, ButtonGroup, Overlay, Popover } from "react-bootstrap"; import styles from "@/components/richTextEditor/toolbar/Toolbar.module.scss"; +import BulletedListButton from "@/components/richTextEditor/toolbar/BulletedListButton"; +import NumberedListButton from "@/components/richTextEditor/toolbar/NumberedListButton"; const OverflowPopover = () => ( + + diff --git a/applications/browser-extension/src/data/model/Asset.ts b/applications/browser-extension/src/data/model/Asset.ts new file mode 100644 index 0000000000..822871eb6d --- /dev/null +++ b/applications/browser-extension/src/data/model/Asset.ts @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { type UUID } from "@/types/stringTypes"; +import { validateUUID } from "@/types/helpers"; +import { type components } from "@/types/swagger"; +import { type RequiredDeep } from "type-fest"; + +export type AssetResponse = Required< + components["schemas"]["AssetPreUpload"]["asset"] +>; +export type AssetPreUploadResponse = RequiredDeep< + components["schemas"]["AssetPreUpload"] +>; + +export type Asset = { + id: UUID; + downloadUrl: URL; + filename: string; + isUploaded: boolean; + updatedAt: Date; + createdAt: Date; +}; + +export type AssetPreUpload = { + asset: Asset; + uploadUrl: URL; + fields: Record; +}; + +function transformAssetResponse(response: AssetResponse): Asset { + return { + id: validateUUID(response.id), + downloadUrl: new URL(response.download_url), + filename: response.filename, + isUploaded: response.is_uploaded, + updatedAt: new Date(response.updated_at), + createdAt: new Date(response.created_at), + }; +} + +export function transformAssetPreUploadResponse( + response: AssetPreUploadResponse, +): AssetPreUpload { + return { + asset: transformAssetResponse(response.asset), + uploadUrl: new URL(response.upload_url), + fields: response.fields as Record, + }; +} diff --git a/applications/browser-extension/src/data/service/api.ts b/applications/browser-extension/src/data/service/api.ts index 5637738992..ebcf9a712f 100644 --- a/applications/browser-extension/src/data/service/api.ts +++ b/applications/browser-extension/src/data/service/api.ts @@ -17,7 +17,7 @@ import { type UUID } from "@/types/stringTypes"; import { DefinitionKinds, type RegistryId } from "@/types/registryTypes"; -import { createApi } from "@reduxjs/toolkit/query/react"; +import { createApi, type FetchBaseQueryError } from "@reduxjs/toolkit/query/react"; import { type Database, type Deployment, @@ -47,6 +47,12 @@ import { type Me, transformMeResponse } from "@/data/model/Me"; import { type UserMilestone } from "@/data/model/UserMilestone"; import { API_PATHS } from "@/data/service/urlPaths"; import { type Team, transformTeamResponse } from "@/data/model/Team"; +import { + type AssetPreUploadResponse, + transformAssetPreUploadResponse, +} from "@/data/model/Asset"; +import axios from "axios"; +import { serializeError } from "serialize-error"; export const appApi = createApi({ reducerPath: "appApi", @@ -68,6 +74,7 @@ export const appApi = createApi({ "StarterBlueprints", "ZapierKey", "Deployments", + "Asset", ], endpoints: (builder) => ({ getMe: builder.query({ @@ -499,6 +506,75 @@ export const appApi = createApi({ }, invalidatesTags: ["Deployments"], }), + uploadAsset: builder.mutation({ + async queryFn( + { databaseId, file }, + { dispatch }, + _, + baseQuery, + ): Promise<{ data: URL } | { error: unknown }> { + const assetPreUploadResult = await baseQuery({ + url: API_PATHS.ASSET_PRE_UPLOAD(databaseId), + method: "post", + data: { + filename: file.name, + }, + }); + + if (assetPreUploadResult.error) { + return { error: assetPreUploadResult.error as FetchBaseQueryError }; + } + + const { + asset: { id: assetId, downloadUrl }, + uploadUrl, + fields, + } = transformAssetPreUploadResponse( + assetPreUploadResult.data as AssetPreUploadResponse, + ); + + const formData = new FormData(); + for (const [key, value] of Object.entries(fields)) { + formData.append(key, value); + } + + formData.append("file", file); + + try { + await axios.post(uploadUrl.href, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + } catch (error) { + if (isAxiosError(error)) { + error.name = "AxiosError"; + return { + // Axios offers its own serialization method, but it reshapes the Error object (doesn't include the response, puts the status on the root level). `useToJSON: false` skips that. + error: serializeError(error, { useToJSON: false }), + }; + } + + return { + error: serializeError(error), + }; + } + + const updateAssetResult = await baseQuery({ + url: API_PATHS.ASSET(databaseId, assetId), + method: "patch", + data: { is_uploaded: true }, + }); + + if (updateAssetResult.error) { + return { + error: updateAssetResult.error as FetchBaseQueryError, + }; + } + + return { data: downloadUrl }; + }, + }), }), }); @@ -530,5 +606,6 @@ export const { useGetDeploymentsQuery, useCreateUserDeploymentMutation, useDeleteUserDeploymentMutation, + useUploadAssetMutation, util, } = appApi; diff --git a/applications/browser-extension/src/data/service/urlPaths.ts b/applications/browser-extension/src/data/service/urlPaths.ts index 03ab256183..af204d301c 100644 --- a/applications/browser-extension/src/data/service/urlPaths.ts +++ b/applications/browser-extension/src/data/service/urlPaths.ts @@ -39,6 +39,11 @@ type PathsValues = Record< >; export const API_PATHS = { + ASSET: (databaseId: string, assetId: string) => + `/api/databases/${databaseId}/assets/${assetId}/`, + ASSET_PRE_UPLOAD: (databaseId: string) => + `/api/databases/${databaseId}/assets/`, + BRICKS: "/api/bricks/", BRICK: (id: string) => `/api/bricks/${id}/`, BRICK_VERSIONS: (id: string) => `/api/bricks/${id}/versions/`, diff --git a/applications/browser-extension/src/extensionConsole/pages/packageEditor/PackageHistory.test.tsx b/applications/browser-extension/src/extensionConsole/pages/packageEditor/PackageHistory.test.tsx index d8062d0108..eef061518e 100644 --- a/applications/browser-extension/src/extensionConsole/pages/packageEditor/PackageHistory.test.tsx +++ b/applications/browser-extension/src/extensionConsole/pages/packageEditor/PackageHistory.test.tsx @@ -53,6 +53,7 @@ describe("PackageHistory", () => { raw_config: "some big yaml file", created_at: "2024-01-24T20:55:41.263846Z" as Timestamp, updated_at: "2024-01-26T23:58:12.270168Z" as Timestamp, + updated_by: {}, }, { id: testPackageId, @@ -61,6 +62,7 @@ describe("PackageHistory", () => { raw_config: "some big yaml file2", created_at: "2024-01-20T16:55:41.263846Z" as Timestamp, updated_at: "2024-01-22T18:58:12.270168Z" as Timestamp, + updated_by: {}, }, ]; diff --git a/applications/browser-extension/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts b/applications/browser-extension/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts index f27bd8f52f..3dbe9d0a2f 100644 --- a/applications/browser-extension/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts +++ b/applications/browser-extension/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts @@ -116,6 +116,14 @@ function useCreateModFromUnsavedMod(): UseCreateModFromUnsavedModReturn { if (activeModId === unsavedModId) { // If the mod list item is selected, reselect the mod item using the new id dispatch(editorActions.setActiveModId(newModId)); + // Preserve the activeModComponentId if there is one + if (activeModComponentFormState?.uuid) { + dispatch( + editorActions.setActiveModComponentId( + activeModComponentFormState.uuid, + ), + ); + } } else if ( activeModComponentFormState?.modMetadata.id === unsavedModId ) { diff --git a/applications/browser-extension/src/pageEditor/hooks/useEnsureFormStates.ts b/applications/browser-extension/src/pageEditor/hooks/useEnsureFormStates.ts index 4e0153ae8d..a5979deab6 100644 --- a/applications/browser-extension/src/pageEditor/hooks/useEnsureFormStates.ts +++ b/applications/browser-extension/src/pageEditor/hooks/useEnsureFormStates.ts @@ -18,7 +18,7 @@ import { actions } from "@/pageEditor/store/editor/editorSlice"; import { modComponentToFormState } from "@/pageEditor/starterBricks/adapter"; import { - selectCurrentModId, + selectActiveModId, selectGetUntouchedActivatedModComponentsForMod, } from "@/pageEditor/store/editor/editorSelectors"; import { useDispatch, useSelector } from "react-redux"; @@ -30,13 +30,13 @@ import { useEffect } from "react"; */ function useEnsureFormStates(): void { const dispatch = useDispatch(); - const currentModId = useSelector(selectCurrentModId); + const activeModId = useSelector(selectActiveModId); const getUntouchedActivatedModComponentsForMod = useSelector( selectGetUntouchedActivatedModComponentsForMod, ); - const untouchedModComponents = currentModId - ? getUntouchedActivatedModComponentsForMod(currentModId) + const untouchedModComponents = activeModId + ? getUntouchedActivatedModComponentsForMod(activeModId) : null; useEffect(() => { diff --git a/applications/browser-extension/src/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames.ts b/applications/browser-extension/src/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames.ts index 21047bcb15..6ee735139b 100644 --- a/applications/browser-extension/src/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames.ts +++ b/applications/browser-extension/src/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames.ts @@ -31,7 +31,7 @@ import { useDispatch, useSelector } from "react-redux"; import { selectActiveModComponentFormState, selectActiveModComponentId, - selectCurrentModId, + selectActiveModId, selectEditorUpdateKey, selectGetModDraftStateForModId, } from "@/pageEditor/store/editor/editorSelectors"; @@ -113,7 +113,7 @@ function updateDraftModInstance() { const state = getState(); const activeModComponentId = selectActiveModComponentId(state); - const modId = selectCurrentModId(state); + const modId = selectActiveModId(state); if (!modId) { // Skip if the modId has somehow become null before the microtask for this async method got scheduled @@ -175,7 +175,7 @@ function updateDraftModInstance() { */ function useRegisterDraftModInstanceOnAllFrames(): void { const dispatch = useDispatch(); - const modId = useSelector(selectCurrentModId); + const modId = useSelector(selectActiveModId); const editorUpdateKey = useSelector(selectEditorUpdateKey); assertNotNullish(modId, "modId is required"); diff --git a/applications/browser-extension/src/pageEditor/modListingPanel/ModListItem.tsx b/applications/browser-extension/src/pageEditor/modListingPanel/ModListItem.tsx index 458e2885ac..44142e75fa 100644 --- a/applications/browser-extension/src/pageEditor/modListingPanel/ModListItem.tsx +++ b/applications/browser-extension/src/pageEditor/modListingPanel/ModListItem.tsx @@ -29,7 +29,7 @@ import { import { useDispatch, useSelector } from "react-redux"; import cx from "classnames"; import { - selectActiveModComponentFormState, + selectActiveModComponentId, selectActiveModId, selectDirtyMetadataForModId, selectExpandedModId, @@ -47,13 +47,12 @@ const ModListItem: React.FC< const dispatch = useDispatch(); const activeModId = useSelector(selectActiveModId); const expandedModId = useSelector(selectExpandedModId); - const activeModComponentFormState = useSelector( - selectActiveModComponentFormState, - ); + const activeModComponentId = useSelector(selectActiveModComponentId); const { id: modId, name: savedName, version: activatedVersion } = modMetadata; - const isActive = activeModId === modId; + const isModComponentSelected = activeModComponentId != null; + const isModSelected = activeModId === modId && !isModComponentSelected; const isExpanded = expandedModId === modId; // TODO: Fix this so it pulls from registry, after registry single-item-api-fetch is implemented @@ -61,10 +60,6 @@ const ModListItem: React.FC< const { data: modDefinition } = useGetModDefinitionQuery({ modId }); const latestModVersion = modDefinition?.metadata?.version; - // Set the alternate background if a mod component in this mod is active - const hasModBackground = - activeModComponentFormState?.modMetadata.id === modId; - const dirtyName = useSelector(selectDirtyMetadataForModId(modId))?.name; const name = dirtyName ?? savedName ?? "Loading..."; @@ -81,16 +76,19 @@ const ModListItem: React.FC< eventKey={modId} as={ListGroup.Item} className={cx(styles.root, "list-group-item-action", { - [styles.modBackground ?? ""]: hasModBackground, + // Set the alternate background if a mod component in this mod is active + [styles.modBackground ?? ""]: isModSelected || isModComponentSelected, })} tabIndex={0} // Avoid using `button` because this item includes more buttons #2343 - active={isActive} - key={`mod-${modId}`} + active={isModSelected} + key={modId} onClick={() => { dispatch(actions.setActiveModId(modId)); // Collapse if the user clicks the mod item when it's already active/selected in the listing pane dispatch( - actions.setExpandedModId(isExpanded && isActive ? null : modId), + actions.setExpandedModId( + isExpanded && isModSelected ? null : modId, + ), ); }} > diff --git a/applications/browser-extension/src/pageEditor/modListingPanel/ModSidebarListItems.tsx b/applications/browser-extension/src/pageEditor/modListingPanel/ModSidebarListItems.tsx index 24146efa13..5538102997 100644 --- a/applications/browser-extension/src/pageEditor/modListingPanel/ModSidebarListItems.tsx +++ b/applications/browser-extension/src/pageEditor/modListingPanel/ModSidebarListItems.tsx @@ -77,25 +77,33 @@ const ModSidebarListItems: React.FunctionComponent = () => { ], ); - const listItems = filteredSidebarItems.map((sidebarItem) => { - const { modMetadata, modComponents } = sidebarItem; + const listItems = useMemo( + () => + filteredSidebarItems.map((sidebarItem) => { + const { modMetadata, modComponents } = sidebarItem; - return ( - - {modComponents.map((modComponentSidebarItem) => ( - - ))} - - ); - }); + return ( + + {modComponents.map((modComponentSidebarItem) => ( + + ))} + + ); + }), + [ + availableActivatedModComponentIds, + availableDraftModComponentIds, + filteredSidebarItems, + ], + ); return ( <> diff --git a/applications/browser-extension/src/pageEditor/modListingPanel/modals/CreateModModal.tsx b/applications/browser-extension/src/pageEditor/modListingPanel/modals/CreateModModal.tsx index 384bcb29b6..96b2175fd4 100644 --- a/applications/browser-extension/src/pageEditor/modListingPanel/modals/CreateModModal.tsx +++ b/applications/browser-extension/src/pageEditor/modListingPanel/modals/CreateModModal.tsx @@ -27,7 +27,7 @@ import { useDispatch, useSelector } from "react-redux"; import { getModalDataSelector, selectActiveModComponentFormState, - selectCurrentModId, + selectActiveModId, selectEditorModalVisibilities, selectModMetadataMap, } from "@/pageEditor/store/editor/editorSelectors"; @@ -138,8 +138,8 @@ const CreateModModalBody: React.FC<{ onHide: () => void }> = ({ onHide }) => { const dispatch = useDispatch(); const isMounted = useIsMounted(); - const currentModId = useSelector(selectCurrentModId); - assertNotNullish(currentModId, "Expected mod or mod component to be active"); + const activeModId = useSelector(selectActiveModId); + assertNotNullish(activeModId, "Expected mod or mod component to be active"); const activeModComponentFormState = useSelector( selectActiveModComponentFormState, @@ -156,11 +156,11 @@ const CreateModModalBody: React.FC<{ onHide: () => void }> = ({ onHide }) => { const { createModFromComponent } = useCreateModFromModComponent(); const { data: modDefinition, isFetching: isModDefinitionFetching } = - useOptionalModDefinition(currentModId); + useOptionalModDefinition(activeModId); const formSchema = useFormSchema(); - const initialFormState = useInitialFormState(currentModId); + const initialFormState = useInitialFormState(activeModId); const onSubmit: OnSubmit = async (values, helpers) => { if (isModDefinitionFetching) { @@ -185,10 +185,10 @@ const CreateModModalBody: React.FC<{ onHide: () => void }> = ({ onHide }) => { ) { // Handle "Save As" case where the mod is unsaved or the user no longer has access to the mod definition assertNotNullish( - currentModId, + activeModId, "Expected mod to be selected in the editor", ); - await createModFromUnsavedMod(currentModId, values); + await createModFromUnsavedMod(activeModId, values); } else { await createModFromMod(modDefinition, values, modalData); } diff --git a/applications/browser-extension/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx b/applications/browser-extension/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx index c81b1fbb38..0064773d9c 100644 --- a/applications/browser-extension/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx +++ b/applications/browser-extension/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx @@ -20,7 +20,7 @@ import { useDispatch, useSelector } from "react-redux"; import { actions } from "@/pageEditor/store/editor/editorSlice"; import { Button, Modal } from "react-bootstrap"; import { - selectCurrentModId, + selectActiveModId, selectEditorModalVisibilities, } from "@/pageEditor/store/editor/editorSelectors"; import { useOptionalModDefinition } from "@/modDefinitions/modDefinitionHooks"; @@ -32,7 +32,7 @@ const SaveAsNewModModal: React.FC = () => { selectEditorModalVisibilities, ); - const modId = useSelector(selectCurrentModId); + const modId = useSelector(selectActiveModId); const { data: mod, isFetching } = useOptionalModDefinition(modId); const modName = mod?.metadata?.name ?? "this mod"; diff --git a/applications/browser-extension/src/pageEditor/modals/addBrickModal/useAddBrick.ts b/applications/browser-extension/src/pageEditor/modals/addBrickModal/useAddBrick.ts index 7f8b459947..bf5ede63df 100644 --- a/applications/browser-extension/src/pageEditor/modals/addBrickModal/useAddBrick.ts +++ b/applications/browser-extension/src/pageEditor/modals/addBrickModal/useAddBrick.ts @@ -74,7 +74,9 @@ function useAddBrick(): AddBrick { brick, compact([ "input" as OutputKey, - ...Object.values(pipelineMap).map((x) => x.blockConfig.outputKey), + ...Object.values(pipelineMap ?? {}).map( + (x) => x.blockConfig.outputKey, + ), ]), ); const newBrick = createNewConfiguredBrick(brick.id, { diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorInvariantMiddleware.ts b/applications/browser-extension/src/pageEditor/store/editor/editorInvariantMiddleware.ts index e9c9c46779..f81845e0db 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorInvariantMiddleware.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorInvariantMiddleware.ts @@ -18,15 +18,15 @@ import type { AnyAction, Dispatch, Middleware } from "@reduxjs/toolkit"; import { type EditorRootState } from "@/pageEditor/store/editor/pageEditorTypes"; import { - selectActiveModComponentId, + selectActiveModComponentFormState, selectActiveModId, selectAllDeletedModComponentIds, - selectCurrentModId, selectExpandedModId, selectModComponentFormStates, } from "@/pageEditor/store/editor/editorSelectors"; import type { EmptyObject } from "type-fest"; import { uniqBy } from "lodash"; +import { isInternalRegistryId } from "@/utils/registryUtils"; class InvariantViolationError extends Error { override name = "InvariantViolationError"; @@ -48,17 +48,28 @@ class InvariantViolationError extends Error { // XXX: in production, should we be attempting to auto-fix these invariants? export function assertEditorInvariants(state: EditorRootState): void { // Assert that a mod and mod component item cannot be selected at the same time - if (selectActiveModId(state) && selectActiveModComponentId(state)) { + const activeModId = selectActiveModId(state); + const activeModComponent = selectActiveModComponentFormState(state); + + if ( + activeModId && + activeModComponent && + activeModId !== activeModComponent?.modMetadata.id && + // When saving, the activeModId and activeModComponent.modMetadata.id aren't updated at the same time. + !isInternalRegistryId(activeModId) + ) { + // Should we dispatch(actions.setActiveModComponentId(null)) + // Would need to change the behavior of the action to handle null throw new InvariantViolationError( - "activeModId and activeModComponentId are both set", + "activeModComponent is not a part of the activeMod", ); } // Assert that the expanded mod must correspond to the selected mod or mod component const expandedModId = selectExpandedModId(state); - if (expandedModId && selectCurrentModId(state) !== expandedModId) { + if (expandedModId && activeModId !== expandedModId) { throw new InvariantViolationError( - "expandedModId does not match active mod/mod component", + "expandedModId does not match active mod", ); } diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorDataPanelSelectors.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorDataPanelSelectors.ts index 07a9e0324d..5737c6e92f 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorDataPanelSelectors.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorDataPanelSelectors.ts @@ -21,7 +21,7 @@ import type { DataPanelTabKey } from "@/pageEditor/tabs/editTab/dataPanel/dataPa import { createSelector } from "@reduxjs/toolkit"; import type { DataPanelTabUIState } from "@/pageEditor/store/editor/uiStateTypes"; import { selectActiveBrickConfigurationUIState } from "@/pageEditor/store/editor/editorSelectors/editorPipelineSelectors"; -import { selectCurrentModId } from "@/pageEditor/store/editor/editorSelectors/editorModSelectors"; +import { selectActiveModId } from "@/pageEditor/store/editor/editorSelectors/editorNavigationSelectors"; export const selectIsDataPanelExpanded = ({ editor }: EditorRootState) => editor.isDataPanelExpanded; @@ -55,9 +55,9 @@ export function selectNodeDataPanelTabState( */ export const selectCurrentFindInModQuery = createSelector( ({ editor }: EditorRootState) => editor.findInModQueryByModId, - selectCurrentModId, + selectActiveModId, (findInModQueryByModId, modId) => { - assertNotNullish(modId, "Expected currentModId"); + assertNotNullish(modId, "Expected activeModId"); // eslint-disable-next-line security/detect-object-injection -- registry id return findInModQueryByModId[modId] ?? { query: "" }; }, diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorModSelectors.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorModSelectors.ts index 847daa71e1..700a06e255 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorModSelectors.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorModSelectors.ts @@ -23,13 +23,11 @@ import { selectModInstances } from "@/store/modComponents/modInstanceSelectors"; import mapModDefinitionToModMetadata from "@/modDefinitions/util/mapModDefinitionToModMetadata"; import { normalizeModOptionsDefinition } from "@/utils/modUtils"; import { - selectActiveModComponentFormState, selectGetModComponentFormStateByModComponentId, selectIsModComponentDirtyById, selectModComponentFormStates, selectNotDeletedActivatedModComponents, } from "@/pageEditor/store/editor/editorSelectors/editorModComponentSelectors"; -import { selectActiveModId } from "@/pageEditor/store/editor/editorSelectors/editorNavigationSelectors"; import type { ModMetadata } from "@/types/modComponentTypes"; import type { UUID } from "@/types/stringTypes"; import { assertNotNullish } from "@/utils/nullishUtils"; @@ -39,19 +37,6 @@ import { collectModVariablesDefinition, } from "@/store/modComponents/modComponentUtils"; -/** - * Select the mod id associated with the selected mod package or mod component. Should be used if the caller doesn't - * need to know if the mod item or one of its components is selected. - * @see selectActiveModId - * @see selectExpandedModId - */ -export const selectCurrentModId = createSelector( - selectActiveModId, - selectActiveModComponentFormState, - (activeModId, activeModComponentFormState) => - activeModId ?? activeModComponentFormState?.modMetadata.id, -); - /// /// MOD METADATA /// diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorNavigationSelectors.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorNavigationSelectors.ts index b8e82070c3..5a7ea3c1a8 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorNavigationSelectors.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorNavigationSelectors.ts @@ -29,9 +29,8 @@ export const selectActiveModComponentId = ({ editor }: EditorRootState) => { }; /** - * Select the id of the mod being edited. NOTE: is null when editing a mod component within the mod. + * Select the id of the mod being edited. * @see selectModId - * @see selectCurrentModId */ export const selectActiveModId = ({ editor }: EditorRootState) => editor.activeModId; diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorPipelineSelectors.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorPipelineSelectors.ts index 14dd456354..9f1c45b3ab 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorPipelineSelectors.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSelectors/editorPipelineSelectors.ts @@ -73,9 +73,9 @@ export const selectActiveNodeInfo = createSelector( const activeModComponentNodeInfoSelector = createSelector( selectActiveBrickPipelineUIState, (_state: EditorRootState, instanceId: UUID) => instanceId, - (uiState: BrickPipelineUIState, instanceId: UUID) => + (uiState, instanceId) => // eslint-disable-next-line security/detect-object-injection -- using a node uuid - uiState.pipelineMap[instanceId], + uiState?.pipelineMap[instanceId], ); export const selectActiveModComponentNodeInfo = @@ -103,13 +103,13 @@ export const selectActiveNodeEventData = createSelector( export const selectPipelineMap = createSelector( selectActiveBrickPipelineUIState, - (uiState: BrickPipelineUIState) => uiState?.pipelineMap, + (uiState) => uiState?.pipelineMap, ); export const selectCollapsedNodes = createSelector( selectActiveBrickPipelineUIState, - (brickPipelineUIState: BrickPipelineUIState) => - Object.entries(brickPipelineUIState.nodeUIStates) + (brickPipelineUIState) => + Object.entries(brickPipelineUIState?.nodeUIStates ?? {}) .map(([nodeId, { collapsed }]) => (collapsed ? nodeId : null)) .filter((nodeId) => nodeId != null), ); @@ -117,7 +117,7 @@ export const selectCollapsedNodes = createSelector( const parentNodeInfoSelector = createSelector( selectActiveBrickPipelineUIState, (_state: EditorRootState, nodeId: UUID) => nodeId, - (brickPipelineUIState: BrickPipelineUIState, nodeId: UUID) => { + (brickPipelineUIState, nodeId) => { if (brickPipelineUIState == null) { return null; } diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSlice.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSlice.ts index ee6c86e3e2..7ea182b2e8 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSlice.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSlice.ts @@ -39,7 +39,7 @@ import { selectActiveBrickConfigurationUIState, selectActiveBrickPipelineUIState, selectActiveModComponentFormState, - selectCurrentModId, + selectActiveModId, selectGetModComponentFormStateByModComponentId, selectGetModComponentFormStatesForMod, selectModComponentFormStates, @@ -349,16 +349,17 @@ export const editorSlice = createSlice({ /// /** - * Activate the mod with the given id. Expands the mod listing pane item if not already expanded + * Select the mod with the given id. Expands the mod listing pane item if not already expanded * @see toggleExpandedModId */ setActiveModId(state, action: PayloadAction) { const modId = action.payload; state.error = null; - state.activeModComponentId = null; state.activeModId = modId; + state.activeModComponentId = null; + if (state.expandedModId !== modId) { state.expandedModId = modId; } @@ -995,12 +996,12 @@ export const editorSlice = createSlice({ setDataPanelTabFindQuery(state, action: PayloadAction<{ query: string }>) { const { query } = action.payload; - const currentModId = selectCurrentModId({ + const activeModId = selectActiveModId({ editor: castEditorState(state), }); - assertNotNullish(currentModId, "Expected currentModId"); + assertNotNullish(activeModId, "Expected activeModId"); - state.findInModQueryByModId[currentModId] = { query }; + state.findInModQueryByModId[activeModId] = { query }; }, /** @@ -1106,7 +1107,7 @@ export const persistEditorConfig: PersistConfig = { // Change the type of localStorage to our overridden version so that it can be exported // See: @/store/StorageInterface.ts storage: localStorage as StorageInterface, - version: 13, + version: 14, migrate: createMigrate(migrations, { debug: Boolean(process.env.DEBUG) }), blacklist: Object.keys(initialEphemeralState), }; diff --git a/applications/browser-extension/src/pageEditor/store/editor/editorSliceHelpers.ts b/applications/browser-extension/src/pageEditor/store/editor/editorSliceHelpers.ts index eb56e0556f..218d34f7c4 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/editorSliceHelpers.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/editorSliceHelpers.ts @@ -172,9 +172,8 @@ export function setActiveModComponentId( ) { state.error = null; state.activeModComponentId = modComponentFormState.uuid; - state.activeModId = null; - state.expandedModId = - modComponentFormState.modMetadata.id ?? state.expandedModId; + state.activeModId = modComponentFormState.modMetadata.id; + state.expandedModId = modComponentFormState.modMetadata.id; state.selectionSeq++; ensureBrickPipelineUIState(state, modComponentFormState.uuid); diff --git a/applications/browser-extension/src/pageEditor/store/editor/pageEditorTypes/editorStateMigrated.ts b/applications/browser-extension/src/pageEditor/store/editor/pageEditorTypes/editorStateMigrated.ts index 5368b0e862..6dd2f59606 100644 --- a/applications/browser-extension/src/pageEditor/store/editor/pageEditorTypes/editorStateMigrated.ts +++ b/applications/browser-extension/src/pageEditor/store/editor/pageEditorTypes/editorStateMigrated.ts @@ -388,12 +388,12 @@ export type EditorStateMigratedV11 = Except< */ export type EditorStateMigratedV12 = Pick< EditorStateMigratedV11, + | "deletedModComponentFormStateIdsByModId" | "dirty" - | "dirtyModVariablesDefinitionById" - | "isDimensionsWarningDismissed" | "dirtyModMetadataById" | "dirtyModOptionsArgsById" - | "modComponentFormStates" - | "deletedModComponentFormStateIdsByModId" | "dirtyModOptionsDefinitionById" + | "dirtyModVariablesDefinitionById" + | "isDimensionsWarningDismissed" + | "modComponentFormStates" >; diff --git a/applications/browser-extension/src/pageEditor/tabs/editTab/EditTab.tsx b/applications/browser-extension/src/pageEditor/tabs/editTab/EditTab.tsx index 6cc18f6533..676d4c55d7 100644 --- a/applications/browser-extension/src/pageEditor/tabs/editTab/EditTab.tsx +++ b/applications/browser-extension/src/pageEditor/tabs/editTab/EditTab.tsx @@ -64,7 +64,7 @@ const EditTab: React.FC<{ function copyBrick(instanceId: UUID) { // eslint-disable-next-line security/detect-object-injection -- UUID - const brickToCopy = pipelineMap[instanceId]?.blockConfig; + const brickToCopy = pipelineMap?.[instanceId]?.blockConfig; if (brickToCopy) { dispatch(actions.copyBrickConfig(brickToCopy)); } diff --git a/applications/browser-extension/src/pageEditor/tabs/editTab/dataPanel/tabs/FindTab/useFindInMod.ts b/applications/browser-extension/src/pageEditor/tabs/editTab/dataPanel/tabs/FindTab/useFindInMod.ts index ba1b9bc81b..f1cb8f75bc 100644 --- a/applications/browser-extension/src/pageEditor/tabs/editTab/dataPanel/tabs/FindTab/useFindInMod.ts +++ b/applications/browser-extension/src/pageEditor/tabs/editTab/dataPanel/tabs/FindTab/useFindInMod.ts @@ -17,7 +17,7 @@ import { useSelector } from "react-redux"; import { - selectCurrentModId, + selectActiveModId, selectGetModComponentFormStatesForMod, } from "@/pageEditor/store/editor/editorSelectors"; import { useMemo } from "react"; @@ -40,14 +40,14 @@ function useFindInMod( // Find/search depends on all mod components having form states with instanceIds assigned useEnsureFormStates(); - const currentModId = useSelector(selectCurrentModId); - assertNotNullish(currentModId, "Expected currentModId"); + const activeModId = useSelector(selectActiveModId); + assertNotNullish(activeModId, "Expected activeModId"); const getModComponentFormStatesForMod = useSelector( selectGetModComponentFormStatesForMod, ); - const modComponentFormStates = getModComponentFormStatesForMod(currentModId); + const modComponentFormStates = getModComponentFormStatesForMod(activeModId); const fuse = useAsyncState(async () => { const items = await SearchIndexVisitor.collectItems(modComponentFormStates); diff --git a/applications/browser-extension/src/store/editorMigrations.ts b/applications/browser-extension/src/store/editorMigrations.ts index 59d6668cd9..653a8d513e 100644 --- a/applications/browser-extension/src/store/editorMigrations.ts +++ b/applications/browser-extension/src/store/editorMigrations.ts @@ -88,6 +88,10 @@ export const migrations: MigrationManifest = { 13: (state: EditorStateMigratedV12 & PersistedState) => // Added findInModOptionsByModId to EditorStateSynced resetEditorStateSynced(state), + 14: (state: EditorStateMigratedV12 & PersistedState) => + // Reset synced state to resolve issues from requiring an + // activeModId to have an activeModComponentId + resetEditorStateSynced(state), }; export function migrateIntegrationDependenciesV1toV2( diff --git a/applications/browser-extension/src/store/editorStorage.test.ts b/applications/browser-extension/src/store/editorStorage.test.ts index 3483bf2a98..dc669db9d1 100644 --- a/applications/browser-extension/src/store/editorStorage.test.ts +++ b/applications/browser-extension/src/store/editorStorage.test.ts @@ -91,6 +91,7 @@ describe("editorStorage", () => { "persist:editor", { ...targetEditorState, + activeModId: null, // When the mod's form states are added, they become active. On remove the active/expanded props are reset activeModComponentId: null, expandedModId: null, diff --git a/applications/browser-extension/src/testUtils/factories/databaseFactories.ts b/applications/browser-extension/src/testUtils/factories/databaseFactories.ts index c69d0b345c..22eaa2a376 100644 --- a/applications/browser-extension/src/testUtils/factories/databaseFactories.ts +++ b/applications/browser-extension/src/testUtils/factories/databaseFactories.ts @@ -27,4 +27,5 @@ export const databaseFactory = define({ name: (n: number) => `Test Database ${n}`, created_at: timestampFactory, last_write_at: timestampFactory, + kind: "Record", }); diff --git a/applications/browser-extension/src/testUtils/factories/deploymentFactories.ts b/applications/browser-extension/src/testUtils/factories/deploymentFactories.ts index 752562f327..405879be01 100644 --- a/applications/browser-extension/src/testUtils/factories/deploymentFactories.ts +++ b/applications/browser-extension/src/testUtils/factories/deploymentFactories.ts @@ -31,6 +31,7 @@ const deploymentPackageFactory = define({ name: "Test Starter Brick", version: (n: number) => normalizeSemVerString(`1.0.${n}`), package_id: (n: number) => validateRegistryId(`test/starter-brick-${n}`), + updated_by: {}, }); export const deploymentFactory = define({ diff --git a/applications/browser-extension/src/testUtils/factories/registryFactories.ts b/applications/browser-extension/src/testUtils/factories/registryFactories.ts index 14670d0361..c91fd0f1d5 100644 --- a/applications/browser-extension/src/testUtils/factories/registryFactories.ts +++ b/applications/browser-extension/src/testUtils/factories/registryFactories.ts @@ -72,5 +72,6 @@ export const packageVersionDeprecatedFactory = define( >(({ config }) => dumpBrickYaml(config), "config"), created_at: timestampFactory, updated_at: timestampFactory, + updated_by: {}, }, ); diff --git a/applications/browser-extension/src/types/swagger.ts b/applications/browser-extension/src/types/swagger.ts index 4e44d41813..c2c0e9a519 100644 --- a/applications/browser-extension/src/types/swagger.ts +++ b/applications/browser-extension/src/types/swagger.ts @@ -231,7 +231,7 @@ export interface paths { delete: operations["destroyUserDatabase"]; options?: never; head?: never; - patch: operations["updateUserDatabase"]; + patch: operations["partialUpdateUserDatabase"]; trace?: never; }; "/api/databases/{id}/permissions/": { @@ -282,6 +282,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/databases/{database_pk}/assets/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAssets"]; + put?: never; + post: operations["createAssetPreUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/databases/{database_pk}/assets/{id}/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["retrieveAsset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["partialUpdateAsset"]; + trace?: never; + }; "/api/databases/{database_pk}/references/": { parameters: { query?: never; @@ -975,6 +1007,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/organizations/{organization_pk}/serviceaccounts/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listServiceAccounts"]; + put?: never; + post: operations["createServiceAccountWithToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/organizations/{organization_pk}/serviceaccounts/{id}/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["retrieveServiceAccount"]; + put?: never; + post?: never; + delete: operations["destroyServiceAccount"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/organizations/{organization_pk}/deployment-keys/": { parameters: { query?: never; @@ -2003,8 +2067,13 @@ export interface components { actor: { /** Format: uuid */ readonly id?: UUID; + readonly name?: string; /** Format: email */ email?: string; + readonly service_account?: boolean; + readonly deployment_key_account?: boolean; + /** Format: date-time */ + date_joined?: Timestamp; }; target_object: { id: string; @@ -2118,6 +2187,7 @@ export interface components { /** @default false */ public: boolean; organizations?: string[]; + message?: string; /** Format: date-time */ readonly updated_at?: Timestamp; /** @description Human-readable name */ @@ -2133,6 +2203,14 @@ export interface components { readonly created_at?: Timestamp; /** Format: date-time */ updated_at?: Timestamp; + updated_by: { + /** Format: uuid */ + readonly id?: UUID; + /** Format: email */ + email?: string; + }; + /** @description Optional commit/changelog message for version */ + message?: string; }; Database: { /** Format: uuid */ @@ -2146,6 +2224,11 @@ export interface components { /** @description Field indicating the record owner */ owner_field?: string | null; user?: string; + /** + * @default Record + * @enum {string} + */ + kind: "Record" | "Asset"; /** Format: date-time */ readonly last_write_at?: Timestamp; readonly num_records?: number; @@ -2178,6 +2261,20 @@ export interface components { /** Format: date-time */ readonly created_at?: Timestamp; }; + Asset: { + /** Format: uuid */ + readonly id?: UUID; + /** Format: uri */ + readonly download_url?: string; + /** @description The original filename. Note this is different than the cloud storage filename. */ + filename?: string; + /** @description True if the file is uploaded to cloud storage */ + is_uploaded?: boolean; + /** Format: date-time */ + readonly created_at?: Timestamp; + /** Format: date-time */ + readonly updated_at?: Timestamp; + }; Deployment: { /** Format: uuid */ readonly id?: UUID; @@ -2197,6 +2294,14 @@ export interface components { readonly created_at?: Timestamp; /** Format: date-time */ updated_at?: Timestamp; + updated_by: { + /** Format: uuid */ + readonly id?: UUID; + /** Format: email */ + email?: string; + }; + /** @description Optional commit/changelog message for version */ + message?: string; }; package_version: string; user?: string; @@ -2233,6 +2338,11 @@ export interface components { /** @description Field indicating the record owner */ owner_field?: string | null; user?: string; + /** + * @default Record + * @enum {string} + */ + kind: "Record" | "Asset"; /** Format: date-time */ readonly last_write_at?: Timestamp; readonly num_records?: number; @@ -2300,6 +2410,14 @@ export interface components { readonly created_at?: Timestamp; /** Format: date-time */ updated_at?: Timestamp; + updated_by: { + /** Format: uuid */ + readonly id?: UUID; + /** Format: email */ + email?: string; + }; + /** @description Optional commit/changelog message for version */ + message?: string; }; readonly bindings?: { /** Format: uuid */ @@ -2402,7 +2520,11 @@ export interface components { last_occurrence_timestamp: Timestamp; message: string; occurrence_count: number; - people_count: number; + users: { + /** Format: uuid */ + id: UUID; + email: string; + }[]; step_label: string | null; user_agent_extension_version: string; }; @@ -2461,7 +2583,7 @@ export interface components { readonly updated_at?: Timestamp; }[]; /** Format: date-time */ - readonly last_active_at?: Timestamp; + readonly last_active_at?: Timestamp | null; }; GroupPackagePermission: { /** Format: uuid */ @@ -2776,6 +2898,8 @@ export interface components { /** Format: date-time */ readonly created_at?: Timestamp; }[]; + /** Format: date-time */ + readonly last_active_at?: Timestamp | null; }; Organization: { /** Format: uuid */ @@ -2801,6 +2925,8 @@ export interface components { id: UUID; name: string; }[]; + /** Format: date-time */ + readonly last_active_at?: Timestamp | null; }[]; scope?: string | null; /** @enum {integer} */ @@ -2851,6 +2977,23 @@ export interface components { role: 1 | 2 | 3 | 4 | 5; }; }; + ServiceAccount: { + /** Format: uuid */ + readonly id?: UUID; + name: string; + /** + * @default 4 + * @enum {integer} + */ + role: 1 | 2 | 3 | 4 | 5; + /** Format: date-time */ + created_at?: Timestamp; + /** + * Format: date-time + * @description The last time the user was active. Currently only set for service accounts. + */ + last_active_at?: Timestamp | null; + }; DeploymentKey: { /** Format: uuid */ readonly id?: UUID; @@ -3174,6 +3317,27 @@ export interface components { [key: string]: unknown; }; }; + AssetPreUpload: { + asset: { + /** Format: uuid */ + readonly id?: UUID; + /** Format: uri */ + readonly download_url?: string; + /** @description The original filename. Note this is different than the cloud storage filename. */ + filename?: string; + /** @description True if the file is uploaded to cloud storage */ + is_uploaded?: boolean; + /** Format: date-time */ + readonly created_at?: Timestamp; + /** Format: date-time */ + readonly updated_at?: Timestamp; + }; + /** Format: uri */ + upload_url: string; + fields: { + [key: string]: unknown; + }; + }; DeploymentMessage: { /** Format: email */ recipient: string; @@ -3205,6 +3369,24 @@ export interface components { } | null; }; Onboarding: Record; + ServiceAccountWithToken: { + /** Format: uuid */ + readonly id?: UUID; + name: string; + /** + * @default 4 + * @enum {integer} + */ + role: 1 | 2 | 3 | 4 | 5; + /** Format: date-time */ + created_at?: Timestamp; + /** + * Format: date-time + * @description The last time the user was active. Currently only set for service accounts. + */ + last_active_at?: Timestamp | null; + readonly token?: string; + }; ProvisionedAccount: { /** Format: email */ email: string; @@ -3276,129 +3458,14 @@ export interface components { readonly created_at?: Timestamp; /** Format: date-time */ updated_at?: Timestamp; - }; - MeV1_0: { - readonly flags?: string[]; - /** Format: uuid */ - readonly id?: UUID; - scope?: string | null; - /** Format: email */ - email?: string; - readonly name?: string; - readonly organization?: { - /** Format: uuid */ - readonly id?: UUID; - name: string; - scope?: string | null; - readonly is_enterprise?: boolean; - readonly control_room?: { - /** Format: uuid */ - readonly id?: UUID; - /** - * Format: uri - * @description The Control Room URL - */ - url: string; - }; - readonly theme?: { - show_sidebar_logo?: boolean; - /** - * Format: uri - * @description The image URL of a custom logo. Image format must be SVG or PNG. - */ - logo?: string | null; - /** - * Format: uri - * @description The image URL of the icon displayed in the browser toolbar. Image format must be PNG. - */ - toolbar_icon?: string | null; - }; - }; - readonly organization_memberships?: { - /** Format: uuid */ - organization: UUID; - organization_name: string; - /** @enum {integer} */ - role: 1 | 2 | 3 | 4 | 5; - scope: string | null; - /** @description True if user is a manager of one or more team deployments */ - readonly is_deployment_manager?: boolean; - control_room: { - /** Format: uuid */ - readonly id?: UUID; - /** - * Format: uri - * @description The Control Room URL - */ - url: string; - }; - }[]; - readonly group_memberships?: { - /** Format: uuid */ - id: UUID; - name: string; - }[]; - readonly partner_principals?: { - /** - * Format: int64 - * @description AA unique identifier used to interact with the Control Room user via the AA API - */ - control_room_user_id: number; - /** Format: uri */ - readonly control_room_url?: string; - }[]; - readonly is_onboarded?: boolean; - readonly milestones?: { - key: string; - /** @description Optional additional information to provide context about the Milestone. */ - metadata?: { - [key: string]: unknown; - } | null; - }[]; - /** @description True if the account is an organization API service account */ - service_account?: boolean; - /** @description True if the account is an organization API deployment key account */ - deployment_key_account?: boolean; - /** @description True if the account is an automated/manual test account */ - test_account?: boolean; - readonly partner?: { - /** Format: uuid */ - readonly id?: UUID; - name: string; - readonly theme?: string; - /** Format: uri */ - documentation_url?: string | null; - }; - readonly enforce_update_millis?: number; - readonly telemetry_organization?: { + updated_by: { /** Format: uuid */ readonly id?: UUID; - name: string; - scope?: string | null; - readonly is_enterprise?: boolean; - readonly control_room?: { - /** Format: uuid */ - readonly id?: UUID; - /** - * Format: uri - * @description The Control Room URL - */ - url: string; - }; - readonly theme?: { - show_sidebar_logo?: boolean; - /** - * Format: uri - * @description The image URL of a custom logo. Image format must be SVG or PNG. - */ - logo?: string | null; - /** - * Format: uri - * @description The image URL of the icon displayed in the browser toolbar. Image format must be PNG. - */ - toolbar_icon?: string | null; - }; + /** Format: email */ + email?: string; }; + /** @description Optional commit/changelog message for version */ + message?: string; }; }; responses: never; @@ -3946,7 +4013,7 @@ export interface operations { }; }; }; - updateUserDatabase: { + partialUpdateUserDatabase: { parameters: { query?: never; header?: never; @@ -4185,6 +4252,118 @@ export interface operations { }; }; }; + listAssets: { + parameters: { + query?: { + /** @description A page number within the paginated result set. */ + page?: number; + /** @description Number of results to return per page. */ + page_size?: number; + }; + header?: never; + path: { + database_pk: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + /** + * @description See https://datatracker.ietf.org/doc/html/rfc8288 for more information. + * @example <https://app.pixiebrix.com/api/databases/{database_pk}/assets/>; rel="first", <https://app.pixiebrix.com/api/databases/{database_pk}/assets/?page=3>; rel="prev", <https://app.pixiebrix.com/api/databases/{database_pk}/assets/?page=5>; rel="next", <https://app.pixiebrix.com/api/databases/{database_pk}/assets/?page=11>; rel="last" + */ + Link?: unknown; + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["Asset"][]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["Asset"][]; + }; + }; + }; + }; + createAssetPreUpload: { + parameters: { + query?: never; + header?: never; + path: { + database_pk: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AssetPreUpload"]; + "application/x-www-form-urlencoded": components["schemas"]["AssetPreUpload"]; + "multipart/form-data": components["schemas"]["AssetPreUpload"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["AssetPreUpload"]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["AssetPreUpload"]; + }; + }; + }; + }; + retrieveAsset: { + parameters: { + query?: never; + header?: never; + path: { + database_pk: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["Asset"]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["Asset"]; + }; + }; + }; + }; + partialUpdateAsset: { + parameters: { + query?: never; + header?: never; + path: { + database_pk: string; + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Asset"]; + "application/x-www-form-urlencoded": components["schemas"]["Asset"]; + "multipart/form-data": components["schemas"]["Asset"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["Asset"]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["Asset"]; + }; + }; + }; + }; listDatabaseReferences: { parameters: { query?: { @@ -6343,6 +6522,109 @@ export interface operations { }; }; }; + listServiceAccounts: { + parameters: { + query?: { + /** @description A page number within the paginated result set. */ + page?: number; + /** @description Number of results to return per page. */ + page_size?: number; + }; + header?: never; + path: { + organization_pk: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + /** + * @description See https://datatracker.ietf.org/doc/html/rfc8288 for more information. + * @example <https://app.pixiebrix.com/api/organizations/{organization_pk}/serviceaccounts/>; rel="first", <https://app.pixiebrix.com/api/organizations/{organization_pk}/serviceaccounts/?page=3>; rel="prev", <https://app.pixiebrix.com/api/organizations/{organization_pk}/serviceaccounts/?page=5>; rel="next", <https://app.pixiebrix.com/api/organizations/{organization_pk}/serviceaccounts/?page=11>; rel="last" + */ + Link?: unknown; + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["ServiceAccount"][]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["ServiceAccount"][]; + }; + }; + }; + }; + createServiceAccountWithToken: { + parameters: { + query?: never; + header?: never; + path: { + organization_pk: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ServiceAccountWithToken"]; + "application/x-www-form-urlencoded": components["schemas"]["ServiceAccountWithToken"]; + "multipart/form-data": components["schemas"]["ServiceAccountWithToken"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["ServiceAccountWithToken"]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["ServiceAccountWithToken"]; + }; + }; + }; + }; + retrieveServiceAccount: { + parameters: { + query?: never; + header?: never; + path: { + organization_pk: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; version=2.0": components["schemas"]["ServiceAccount"]; + "application/vnd.pixiebrix.api+json; version=2.0": components["schemas"]["ServiceAccount"]; + }; + }; + }; + }; + destroyServiceAccount: { + parameters: { + query?: never; + header?: never; + path: { + organization_pk: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listDeploymentKeys: { parameters: { query?: { diff --git a/knip.mjs b/knip.mjs index 5a8e0d145f..eeb9168298 100644 --- a/knip.mjs +++ b/knip.mjs @@ -161,6 +161,7 @@ const knipConfig = { "@tiptap/react", "@tiptap/extension-underline", "@tiptap/extension-link", + "@tiptap/extension-image", // False positives flagged in --production checks. // In non-production runs, these entries are flagged as unnecessary ignoreDependencies entries diff --git a/package-lock.json b/package-lock.json index c44dd6d5fd..184408d4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "@rjsf/core": "^5.22.3", "@rjsf/utils": "^5.22.3", "@szhsin/react-menu": "^4.2.2", + "@tiptap/extension-image": "^2.9.1", "@tiptap/extension-link": "^2.9.1", "@tiptap/extension-underline": "^2.9.1", "@tiptap/pm": "^2.9.1", @@ -108,7 +109,7 @@ "kbar": "^0.1.0-beta.45", "lodash-es": "^4.17.21", "mark.js": "^8.11.1", - "marked": "^14.1.3", + "marked": "^15.0.0", "memoize-one": "^6.0.0", "mustache": "^4.2.0", "nunjucks": "^3.2.4", @@ -7866,6 +7867,18 @@ "@tiptap/pm": "^2.7.0" } }, + "node_modules/@tiptap/extension-image": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.9.1.tgz", + "integrity": "sha512-aGqJnsuS8oagIhsx7wetm8jw4NEDsOV0OSx4FQ4VPlUqWlnzK0N+erFKKJmXTdAxL8PGzoPSlITFH63MV3eV3Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-italic": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.9.1.tgz", @@ -20899,9 +20912,9 @@ } }, "node_modules/marked": { - "version": "14.1.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz", - "integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.0.tgz", + "integrity": "sha512-0mouKmBROJv/WSHJBPZZyYofUgawMChnD5je/g+aOBXsHDjb/IsnTQj7mnhQZu+qPJmRQ0ecX3mLGEUm3BgwYA==", "bin": { "marked": "bin/marked.js" },