diff --git a/x-pack/plugins/notes_test/common/constants.ts b/x-pack/plugins/notes_test/common/constants.ts new file mode 100644 index 0000000000000..33b2f846ef533 --- /dev/null +++ b/x-pack/plugins/notes_test/common/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const NOTE_OBJ_TYPE = 'note'; +export const VIEW_NOTE_PATH = 'viewNote'; diff --git a/x-pack/plugins/notes_test/common/index.ts b/x-pack/plugins/notes_test/common/index.ts new file mode 100644 index 0000000000000..f4d74984a7d78 --- /dev/null +++ b/x-pack/plugins/notes_test/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './constants'; +export * from './types'; diff --git a/x-pack/plugins/notes_test/common/types.ts b/x-pack/plugins/notes_test/common/types.ts new file mode 100644 index 0000000000000..5420b0fd3ab7d --- /dev/null +++ b/x-pack/plugins/notes_test/common/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface NoteAttributes { + subject: string; + text: string; + createdAt: Date; +} diff --git a/x-pack/plugins/notes_test/kibana.json b/x-pack/plugins/notes_test/kibana.json new file mode 100644 index 0000000000000..15ac81f703784 --- /dev/null +++ b/x-pack/plugins/notes_test/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "notesTest", + "owner": { "name": "Platform Security", "githubTeam": "kibana-security" }, + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": ["spaces"] +} diff --git a/x-pack/plugins/notes_test/public/app.tsx b/x-pack/plugins/notes_test/public/app.tsx new file mode 100644 index 0000000000000..f01ab7e0c12f9 --- /dev/null +++ b/x-pack/plugins/notes_test/public/app.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Switch, Route } from 'react-router-dom'; + +import type { AppMountParameters, HttpStart } from 'src/core/public'; +import type { SpacesPluginStart } from '../../spaces/public'; +import { VIEW_NOTE_PATH } from '../common'; +import { NotesList } from './notes_list'; +import type { Services } from './services'; +import { ViewNote } from './view_note'; + +interface RenderParams { + services: Services; + appMountParams: AppMountParameters; + http: HttpStart; + spacesApi?: SpacesPluginStart; +} + +export const renderApp = ({ services, appMountParams, http, spacesApi }: RenderParams) => { + const { element, history } = appMountParams; + + ReactDOM.render( + + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/plugins/notes_test/public/create_note_form.tsx b/x-pack/plugins/notes_test/public/create_note_form.tsx new file mode 100644 index 0000000000000..ea33cc3e56fe8 --- /dev/null +++ b/x-pack/plugins/notes_test/public/create_note_form.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiButton, EuiFieldText, EuiForm, EuiFormRow, EuiText, EuiTextArea } from '@elastic/eui'; + +import type { Services } from './services'; + +interface Props { + services: Services; + onAfterCreate: () => void; +} + +export function CreateNoteForm({ services, onAfterCreate }: Props) { + const [subject, setSubject] = useState(''); + const [text, setText] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isInvalid, setIsInvalid] = useState(false); + + const saveNote = useCallback(async () => { + if (isSaving) return; + setIsSaving(true); + + if (!subject || !text) { + setIsInvalid(true); + } else { + setIsInvalid(false); + await services.createNote(subject, text); + setSubject(''); + setText(''); + services.addSuccessToast('Note created!'); + onAfterCreate(); + } + + setIsSaving(false); + }, [subject, text, services, isSaving, onAfterCreate]); + + return ( + <> + + + Create a new note + + + setSubject(event.target.value)} + /> + + + setText(event.target.value)} + /> + + + Save + + + + ); +} diff --git a/x-pack/plugins/notes_test/public/index.ts b/x-pack/plugins/notes_test/public/index.ts new file mode 100644 index 0000000000000..892c787323ed0 --- /dev/null +++ b/x-pack/plugins/notes_test/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializer } from 'src/core/public'; +import { NotesTestPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}> = () => new NotesTestPlugin(); diff --git a/x-pack/plugins/notes_test/public/notes_list.tsx b/x-pack/plugins/notes_test/public/notes_list.tsx new file mode 100644 index 0000000000000..b59f01f691197 --- /dev/null +++ b/x-pack/plugins/notes_test/public/notes_list.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiInMemoryTable, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { Link } from 'react-router-dom'; + +import type { SimpleSavedObject } from 'src/core/public'; +import type { NoteAttributes } from '../common'; +import { VIEW_NOTE_PATH } from '../common'; +import { CreateNoteForm } from './create_note_form'; +import type { Services } from './services'; + +interface Props { + services: Services; +} + +type NoteObject = SimpleSavedObject; + +export function NotesList({ services }: Props) { + const { findAllNotes } = services; + const [isFetching, setIsFetching] = useState(false); + const [notes, setNotes] = useState([]); + + const fetchNotes = useCallback(async () => { + if (isFetching) return; + setIsFetching(true); + + const response = await findAllNotes(); + setNotes(response); + + setIsFetching(false); + }, [isFetching, findAllNotes, setNotes]); + + useEffect(() => { + fetchNotes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + +

Notes

+
+
+ {notes.length ? ( + <> + + items={notes} + columns={[ + { + field: 'attributes.subject', + name: 'Subject', + render: (value, record) => { + const { id, attributes } = record; + return {attributes.subject}; + }, + }, + { + field: 'attributes.createdAt', + name: 'Created at', + dataType: 'date', + }, + ]} + pagination={false} + sorting={{ sort: { field: 'attributes.createdAt', direction: 'desc' } }} + /> + + + ) : null} + fetchNotes()} /> +
+
+
+ ); +} diff --git a/x-pack/plugins/notes_test/public/plugin.tsx b/x-pack/plugins/notes_test/public/plugin.tsx new file mode 100644 index 0000000000000..392edfee6c004 --- /dev/null +++ b/x-pack/plugins/notes_test/public/plugin.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart, Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; +import type { SpacesPluginStart } from '../../spaces/public'; +import { getServices } from './services'; + +interface PluginStartDeps { + spaces?: SpacesPluginStart; +} + +export class NotesTestPlugin implements Plugin<{}, {}, {}, PluginStartDeps> { + public setup(core: CoreSetup) { + core.application.register({ + id: 'notesTest', + title: 'Notes test', + async mount(appMountParams: AppMountParameters) { + const [coreStart, pluginStartDeps] = await core.getStartServices(); + const services = getServices(coreStart); + const { http } = coreStart; + const { spaces: spacesApi } = pluginStartDeps; + const { renderApp } = await import('./app'); + return renderApp({ services, appMountParams, http, spacesApi }); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/notes_test/public/services.ts b/x-pack/plugins/notes_test/public/services.ts new file mode 100644 index 0000000000000..fc4e7ebf89b42 --- /dev/null +++ b/x-pack/plugins/notes_test/public/services.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart, ResolvedSimpleSavedObject, SimpleSavedObject } from 'src/core/public'; + +import type { NoteAttributes } from '../common'; +import { NOTE_OBJ_TYPE } from '../common'; + +export interface Services { + createNote: (subject: string, text: string) => Promise; + findAllNotes: () => Promise>>; + getNoteById: (id: string) => Promise>; + addSuccessToast: (message: string) => void; +} + +export function getServices(core: CoreStart): Services { + const savedObjectsClient = core.savedObjects.client; + + return { + createNote: async (subject: string, text: string) => { + const attributes = { subject, text, createdAt: new Date() }; + await savedObjectsClient.create(NOTE_OBJ_TYPE, attributes); + }, + findAllNotes: async () => { + const findResult = await savedObjectsClient.find({ + type: NOTE_OBJ_TYPE, + perPage: 100, + }); + return findResult.savedObjects; + }, + getNoteById: async (id: string) => { + const resolveResult = await savedObjectsClient.resolve(NOTE_OBJ_TYPE, id); + return resolveResult; + }, + addSuccessToast: (message: string) => core.notifications.toasts.addSuccess(message), + }; +} diff --git a/x-pack/plugins/notes_test/public/view_note.tsx b/x-pack/plugins/notes_test/public/view_note.tsx new file mode 100644 index 0000000000000..a1d86c88463bf --- /dev/null +++ b/x-pack/plugins/notes_test/public/view_note.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiLoadingSpinner, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { Link, useParams } from 'react-router-dom'; + +import type { HttpStart, ResolvedSimpleSavedObject } from 'src/core/public'; +import type { SpacesPluginStart } from '../../spaces/public'; +import type { NoteAttributes } from '../common'; +import { VIEW_NOTE_PATH } from '../common'; +import type { Services } from './services'; + +interface Params { + noteId: string; +} +interface Props { + services: Services; + http: HttpStart; + spacesApi?: SpacesPluginStart; +} + +type ResolvedNote = ResolvedSimpleSavedObject; +const OBJECT_NOUN = 'note'; + +export function ViewNote({ services, http, spacesApi }: Props) { + const { noteId } = useParams(); + const [resolvedNote, setResolvedNote] = useState(null); + const note = resolvedNote?.saved_object; + + const fetchNote = async () => { + const resolveResult = await services.getNoteById(noteId); + + if (spacesApi && resolveResult.outcome === 'aliasMatch') { + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'aliasMatch' + const newPath = `/${VIEW_NOTE_PATH}/${newObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) + await spacesApi.ui.redirectLegacyUrl(newPath, OBJECT_NOUN); + return; + } + + setResolvedNote(resolveResult); + }; + + useEffect(() => { + fetchNote(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noteId]); + + const getLegacyUrlConflictCallout = () => { + // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario + if (spacesApi && resolvedNote) { + if (resolvedNote.outcome === 'conflict') { + // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a + // callout with a warning for the user, and provide a way for them to navigate to the other object. + const currentObjectId = resolvedNote.saved_object.id; + const otherObjectId = resolvedNote.alias_target_id!; // This is always defined if outcome === 'conflict' + const otherObjectPath = `/${VIEW_NOTE_PATH}/${otherObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) + return ( + <> + {spacesApi.ui.components.getLegacyUrlConflict({ + objectNoun: OBJECT_NOUN, + currentObjectId, + otherObjectId, + otherObjectPath, + })} + + + ); + } + } + return null; + }; + + return ( + + + + {/* If we have a legacy URL conflict callout to display, show it at the top of the page */} + {getLegacyUrlConflictCallout()} + + +

View note

+
+
+ {note ? ( + <> + +

{note.attributes.subject}

+

{note.attributes.createdAt}

+
+                  {note.attributes.text}
+                
+
+ + + ) : ( + + )} + Back to notes list +
+
+
+ ); +} diff --git a/x-pack/plugins/notes_test/server/index.ts b/x-pack/plugins/notes_test/server/index.ts new file mode 100644 index 0000000000000..8c49faa4ff146 --- /dev/null +++ b/x-pack/plugins/notes_test/server/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializer } from 'src/core/server'; + +import { NotesTestPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}> = () => new NotesTestPlugin(); diff --git a/x-pack/plugins/notes_test/server/plugin.ts b/x-pack/plugins/notes_test/server/plugin.ts new file mode 100644 index 0000000000000..9bb81a648c28b --- /dev/null +++ b/x-pack/plugins/notes_test/server/plugin.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Plugin, CoreSetup, CoreStart } from 'src/core/server'; +import { registerSavedObject } from './saved_objects'; + +export class NotesTestPlugin implements Plugin<{}, {}> { + public setup(core: CoreSetup) { + registerSavedObject(core.savedObjects); + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/notes_test/server/saved_objects.ts b/x-pack/plugins/notes_test/server/saved_objects.ts new file mode 100644 index 0000000000000..1f1e166755fb4 --- /dev/null +++ b/x-pack/plugins/notes_test/server/saved_objects.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsServiceSetup, SavedObjectsType } from 'src/core/server'; +import type { NoteAttributes } from '../common'; +import { NOTE_OBJ_TYPE } from '../common'; + +export function registerSavedObject(savedObjects: SavedObjectsServiceSetup) { + savedObjects.registerType(noteSavedObjectType); +} + +const noteSavedObjectType: SavedObjectsType = { + name: NOTE_OBJ_TYPE, + hidden: false, + management: { + importableAndExportable: true, + icon: 'document', + getTitle: ({ attributes }) => attributes.subject, + }, + mappings: { dynamic: false, properties: {} }, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', +}; diff --git a/x-pack/plugins/notes_test/tsconfig.json b/x-pack/plugins/notes_test/tsconfig.json new file mode 100644 index 0000000000000..0edd5023c4e3a --- /dev/null +++ b/x-pack/plugins/notes_test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +}