Adapt react-client to use the real backend API (#1545)
Co-authored-by: Philip Molares <philip.molares@udo.edu> Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de>
12
.reuse/dep5
|
@ -3,11 +3,7 @@ Upstream-Name: react-client
|
||||||
Upstream-Contact: The HedgeDoc developers <license@hedgedoc.org>
|
Upstream-Contact: The HedgeDoc developers <license@hedgedoc.org>
|
||||||
Source: https://github.com/hedgedoc/react-client
|
Source: https://github.com/hedgedoc/react-client
|
||||||
|
|
||||||
Files: public/mock-backend/public/*
|
Files: public/mock-public/*
|
||||||
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
License: CC0-1.0
|
|
||||||
|
|
||||||
Files: public/mock-backend/api/*
|
|
||||||
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
License: CC0-1.0
|
License: CC0-1.0
|
||||||
|
|
||||||
|
@ -19,14 +15,10 @@ Files: locales/*
|
||||||
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
License: CC-BY-SA-4.0
|
License: CC-BY-SA-4.0
|
||||||
|
|
||||||
Files: public/mock-backend/public/img/highres.jpg
|
Files: public/mock-public/img/highres.jpg
|
||||||
Copyright: Vincent van Gogh
|
Copyright: Vincent van Gogh
|
||||||
License: CC0-1.0
|
License: CC0-1.0
|
||||||
|
|
||||||
Files: public/index.html
|
|
||||||
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
License: CC0-1.0
|
|
||||||
|
|
||||||
Files: public/robots.txt
|
Files: public/robots.txt
|
||||||
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
License: CC0-1.0
|
License: CC0-1.0
|
||||||
|
|
|
@ -4,14 +4,16 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { testNoteId } from '../support/visit-test-editor'
|
||||||
|
|
||||||
describe('Delete note', () => {
|
describe('Delete note', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.visitTestNote()
|
cy.visitTestNote()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('correctly deletes a note', () => {
|
it('correctly deletes a note', () => {
|
||||||
cy.intercept('DELETE', '/mock-backend/api/private/notes/mock_note_id', {
|
cy.intercept('DELETE', `/api/mock-backend/private/notes/${testNoteId}`, {
|
||||||
statusCode: 200
|
statusCode: 204
|
||||||
})
|
})
|
||||||
cy.getByCypressId('sidebar.deleteNote.button').click()
|
cy.getByCypressId('sidebar.deleteNote.button').click()
|
||||||
cy.getByCypressId('sidebar.deleteNote.modal').should('be.visible')
|
cy.getByCypressId('sidebar.deleteNote.modal').should('be.visible')
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -16,13 +16,13 @@ describe('File upload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
url: '/mock-backend/api/private/media/upload-post'
|
url: '/api/mock-backend/private/media'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
statusCode: 200,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
link: imageUrl
|
url: imageUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -69,8 +69,8 @@ describe('File upload', () => {
|
||||||
it('fails', () => {
|
it('fails', () => {
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
url: '/mock-backend/api/private/media/upload-post'
|
url: '/api/mock-backend/private/media'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
statusCode: 400
|
statusCode: 400
|
||||||
|
|
|
@ -24,7 +24,7 @@ describe('History', () => {
|
||||||
describe('is as given when not empty', () => {
|
describe('is as given when not empty', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.clearLocalStorage('history')
|
cy.clearLocalStorage('history')
|
||||||
cy.intercept('GET', '/mock-backend/api/private/me/history', {
|
cy.intercept('GET', '/api/mock-backend/private/me/history', {
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
identifier: 'cypress',
|
identifier: 'cypress',
|
||||||
|
@ -51,7 +51,7 @@ describe('History', () => {
|
||||||
describe('is untitled when not empty', () => {
|
describe('is untitled when not empty', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.clearLocalStorage('history')
|
cy.clearLocalStorage('history')
|
||||||
cy.intercept('GET', '/mock-backend/api/private/me/history', {
|
cy.intercept('GET', '/api/mock-backend/private/me/history', {
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
identifier: 'cypress-no-title',
|
identifier: 'cypress-no-title',
|
||||||
|
@ -84,7 +84,7 @@ describe('History', () => {
|
||||||
|
|
||||||
describe('working', () => {
|
describe('working', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept('PUT', '/mock-backend/api/private/me/history/features', (req) => {
|
cy.intercept('PUT', '/api/mock-backend/private/me/history/features', (req) => {
|
||||||
req.reply(200, req.body)
|
req.reply(200, req.body)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -106,7 +106,7 @@ describe('History', () => {
|
||||||
|
|
||||||
describe('failing', () => {
|
describe('failing', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept('PUT', '/mock-backend/api/private/me/history/features', {
|
cy.intercept('PUT', '/api/mock-backend/private/me/history/features', {
|
||||||
statusCode: 401
|
statusCode: 401
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -128,7 +128,7 @@ describe('History', () => {
|
||||||
describe('Import', () => {
|
describe('Import', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.clearLocalStorage('history')
|
cy.clearLocalStorage('history')
|
||||||
cy.intercept('GET', '/mock-backend/api/private/me/history', {
|
cy.intercept('GET', '/api/mock-backend/private/me/history', {
|
||||||
body: []
|
body: []
|
||||||
})
|
})
|
||||||
cy.visitHistory()
|
cy.visitHistory()
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
describe('Intro page', () => {
|
describe('Intro page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept('/mock-backend/public/intro.md', 'test content')
|
cy.intercept('/mock-public/intro.md', 'test content')
|
||||||
cy.visitHome()
|
cy.visitHome()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ describe('Intro page', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("won't show anything if no content was found", () => {
|
it("won't show anything if no content was found", () => {
|
||||||
cy.intercept('/mock-backend/public/intro.md', {
|
cy.intercept('/mock-public/intro.md', {
|
||||||
statusCode: 404
|
statusCode: 404
|
||||||
})
|
})
|
||||||
cy.visitHome()
|
cy.visitHome()
|
||||||
|
|
|
@ -11,13 +11,13 @@ const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
|
||||||
|
|
||||||
describe('Motd', () => {
|
describe('Motd', () => {
|
||||||
const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => {
|
const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => {
|
||||||
cy.intercept('GET', '/mock-backend/public/motd.md', {
|
cy.intercept('GET', '/mock-public/motd.md', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED },
|
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED },
|
||||||
body: content
|
body: content
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.intercept('HEAD', '/mock-backend/public/motd.md', {
|
cy.intercept('HEAD', '/mock-public/motd.md', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }
|
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,7 +8,7 @@ describe('profile page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
url: '/mock-backend/api/private/tokens',
|
url: '/api/mock-backend/private/tokens',
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -25,7 +25,7 @@ describe('profile page', () => {
|
||||||
)
|
)
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
url: '/mock-backend/api/private/tokens',
|
url: '/api/mock-backend/private/tokens',
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -36,16 +36,18 @@ describe('profile page', () => {
|
||||||
createdAt: '2021-11-21T01:11:12+01:00',
|
createdAt: '2021-11-21T01:11:12+01:00',
|
||||||
lastUsed: '2021-11-21T01:11:12+01:00',
|
lastUsed: '2021-11-21T01:11:12+01:00',
|
||||||
validUntil: '2023-11-21'
|
validUntil: '2023-11-21'
|
||||||
}
|
},
|
||||||
|
statusCode: 201
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
url: '/mock-backend/api/private/tokens/cypress',
|
url: '/api/mock-backend/private/tokens/cypress',
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: []
|
body: [],
|
||||||
|
statusCode: 204
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
cy.visit('/profile', { retryOnNetworkFailure: true })
|
cy.visit('/profile', { retryOnNetworkFailure: true })
|
||||||
|
|
|
@ -4,28 +4,12 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const authProvidersDisabled = {
|
import type { AuthProvider } from '../../src/api/config/types'
|
||||||
facebook: false,
|
import { AuthProviderType } from '../../src/api/config/types'
|
||||||
github: false,
|
|
||||||
twitter: false,
|
|
||||||
gitlab: false,
|
|
||||||
dropbox: false,
|
|
||||||
ldap: false,
|
|
||||||
google: false,
|
|
||||||
saml: false,
|
|
||||||
oauth2: false,
|
|
||||||
local: false
|
|
||||||
}
|
|
||||||
|
|
||||||
const initLoggedOutTestWithCustomAuthProviders = (
|
const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: AuthProvider[]) => {
|
||||||
cy: Cypress.cy,
|
|
||||||
enabledProviders: Partial<typeof authProvidersDisabled>
|
|
||||||
) => {
|
|
||||||
cy.loadConfig({
|
cy.loadConfig({
|
||||||
authProviders: {
|
authProviders: enabledProviders
|
||||||
...authProvidersDisabled,
|
|
||||||
...enabledProviders
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
cy.visitHome()
|
cy.visitHome()
|
||||||
cy.logout()
|
cy.logout()
|
||||||
|
@ -41,55 +25,71 @@ describe('When logged-in, ', () => {
|
||||||
describe('When logged-out ', () => {
|
describe('When logged-out ', () => {
|
||||||
describe('and no auth-provider is enabled, ', () => {
|
describe('and no auth-provider is enabled, ', () => {
|
||||||
it('sign-in button is hidden', () => {
|
it('sign-in button is hidden', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {})
|
initLoggedOutTestWithCustomAuthProviders(cy, [])
|
||||||
cy.getByCypressId('sign-in-button').should('not.exist')
|
cy.getByCypressId('sign-in-button').should('not.exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('and an interactive auth-provider is enabled, ', () => {
|
describe('and an interactive auth-provider is enabled, ', () => {
|
||||||
it('sign-in button points to login route: internal', () => {
|
it('sign-in button points to login route: internal', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
local: true
|
{
|
||||||
})
|
type: AuthProviderType.LOCAL
|
||||||
|
}
|
||||||
|
])
|
||||||
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sign-in button points to login route: ldap', () => {
|
it('sign-in button points to login route: ldap', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
ldap: true
|
{
|
||||||
})
|
type: AuthProviderType.LDAP,
|
||||||
|
identifier: 'cy-ldap',
|
||||||
|
providerName: 'cy LDAP'
|
||||||
|
}
|
||||||
|
])
|
||||||
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('and only one one-click auth-provider is enabled, ', () => {
|
describe('and only one one-click auth-provider is enabled, ', () => {
|
||||||
it('sign-in button points to auth-provider', () => {
|
it('sign-in button points to auth-provider', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
saml: true
|
{
|
||||||
})
|
type: AuthProviderType.GITHUB
|
||||||
|
}
|
||||||
|
])
|
||||||
cy.getByCypressId('sign-in-button')
|
cy.getByCypressId('sign-in-button')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
// The absolute URL is used because it is defined as API base URL absolute.
|
// The absolute URL is used because it is defined as API base URL absolute.
|
||||||
.should('have.attr', 'href', '/mock-backend/auth/saml')
|
.should('have.attr', 'href', '/auth/github')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('and multiple one-click auth-providers are enabled, ', () => {
|
describe('and multiple one-click auth-providers are enabled, ', () => {
|
||||||
it('sign-in button points to login route', () => {
|
it('sign-in button points to login route', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
saml: true,
|
{
|
||||||
github: true
|
type: AuthProviderType.GITHUB
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.GOOGLE
|
||||||
|
}
|
||||||
|
])
|
||||||
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('and one-click- as well as interactive auth-providers are enabled, ', () => {
|
describe('and one-click- as well as interactive auth-providers are enabled, ', () => {
|
||||||
it('sign-in button points to login route', () => {
|
it('sign-in button points to login route', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, {
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
saml: true,
|
{
|
||||||
local: true
|
type: AuthProviderType.GITHUB
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.LOCAL
|
||||||
|
}
|
||||||
|
])
|
||||||
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AuthProviderType } from '../../src/api/config/types'
|
||||||
|
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
loadConfig(): Chainable<Window>
|
loadConfig(): Chainable<Window>
|
||||||
|
@ -12,43 +14,70 @@ declare namespace Cypress {
|
||||||
|
|
||||||
export const branding = {
|
export const branding = {
|
||||||
name: 'DEMO Corp',
|
name: 'DEMO Corp',
|
||||||
logo: '/mock-backend/public/img/demo.png'
|
logo: '/mock-public/img/demo.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authProviders = {
|
export const authProviders = [
|
||||||
facebook: true,
|
{
|
||||||
github: true,
|
type: AuthProviderType.FACEBOOK
|
||||||
twitter: true,
|
},
|
||||||
gitlab: true,
|
{
|
||||||
dropbox: true,
|
type: AuthProviderType.GITHUB
|
||||||
ldap: true,
|
},
|
||||||
google: true,
|
{
|
||||||
saml: true,
|
type: AuthProviderType.TWITTER
|
||||||
oauth2: true,
|
},
|
||||||
local: true
|
{
|
||||||
}
|
type: AuthProviderType.DROPBOX
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.GOOGLE
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.LOCAL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.LDAP,
|
||||||
|
identifier: 'test-ldap',
|
||||||
|
providerName: 'Test LDAP'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.OAUTH2,
|
||||||
|
identifier: 'test-oauth2',
|
||||||
|
providerName: 'Test OAuth2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.SAML,
|
||||||
|
identifier: 'test-saml',
|
||||||
|
providerName: 'Test SAML'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AuthProviderType.GITLAB,
|
||||||
|
identifier: 'test-gitlab',
|
||||||
|
providerName: 'Test GitLab'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
allowAnonymous: true,
|
allowAnonymous: true,
|
||||||
|
allowRegister: true,
|
||||||
authProviders: authProviders,
|
authProviders: authProviders,
|
||||||
branding: branding,
|
branding: branding,
|
||||||
customAuthNames: {
|
useImageProxy: false,
|
||||||
ldap: 'FooBar',
|
|
||||||
oauth2: 'Olaf2',
|
|
||||||
saml: 'aufSAMLn.de'
|
|
||||||
},
|
|
||||||
maxDocumentLength: 200,
|
|
||||||
specialUrls: {
|
specialUrls: {
|
||||||
privacy: 'https://example.com/privacy',
|
privacy: 'https://example.com/privacy',
|
||||||
termsOfUse: 'https://example.com/termsOfUse',
|
termsOfUse: 'https://example.com/termsOfUse',
|
||||||
imprint: 'https://example.com/imprint'
|
imprint: 'https://example.com/imprint'
|
||||||
},
|
},
|
||||||
plantumlServer: 'http://mock-plantuml.local',
|
|
||||||
version: {
|
version: {
|
||||||
version: 'mock',
|
major: 0,
|
||||||
sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
minor: 0,
|
||||||
issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
patch: 0,
|
||||||
|
preRelease: '',
|
||||||
|
commit: 'MOCK'
|
||||||
},
|
},
|
||||||
|
plantumlServer: 'http://mock-plantuml.local',
|
||||||
|
maxDocumentLength: 200,
|
||||||
iframeCommunication: {
|
iframeCommunication: {
|
||||||
editorOrigin: 'http://127.0.0.1:3001/',
|
editorOrigin: 'http://127.0.0.1:3001/',
|
||||||
rendererOrigin: 'http://127.0.0.1:3001/'
|
rendererOrigin: 'http://127.0.0.1:3001/'
|
||||||
|
@ -56,7 +85,7 @@ export const config = {
|
||||||
}
|
}
|
||||||
|
|
||||||
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
|
||||||
return cy.intercept('/mock-backend/api/private/config', {
|
return cy.intercept('/api/mock-backend/private/config', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
...config,
|
...config,
|
||||||
|
@ -68,11 +97,11 @@ Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) =
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.loadConfig()
|
cy.loadConfig()
|
||||||
|
|
||||||
cy.intercept('GET', '/mock-backend/public/motd.md', {
|
cy.intercept('GET', '/mock-public/motd.md', {
|
||||||
body: '404 Not Found!',
|
body: '404 Not Found!',
|
||||||
statusCode: 404
|
statusCode: 404
|
||||||
})
|
})
|
||||||
cy.intercept('HEAD', '/mock-backend/public/motd.md', {
|
cy.intercept('HEAD', '/mock-public/motd.md', {
|
||||||
statusCode: 404
|
statusCode: 404
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -25,6 +25,6 @@ import './config'
|
||||||
import './fill'
|
import './fill'
|
||||||
import './get-by-id'
|
import './get-by-id'
|
||||||
import './get-iframe-content'
|
import './get-iframe-content'
|
||||||
import './login'
|
import './logout'
|
||||||
import './visit-test-editor'
|
import './visit-test-editor'
|
||||||
import './visit'
|
import './visit'
|
||||||
|
|
|
@ -3,26 +3,30 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const testNoteId = 'test'
|
export const testNoteId = 'test'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept(`/mock-backend/api/private/notes/${testNoteId}-get`, {
|
cy.intercept(`/api/mock-backend/private/notes/${testNoteId}`, {
|
||||||
content: '',
|
content: '',
|
||||||
metadata: {
|
metadata: {
|
||||||
id: 'mock_note_id',
|
id: testNoteId,
|
||||||
alias: 'mockNote',
|
alias: ['mock-note'],
|
||||||
version: 2,
|
primaryAlias: 'mock-note',
|
||||||
viewCount: 0,
|
title: 'Mock Note',
|
||||||
|
description: 'Mocked note for testing',
|
||||||
|
tags: ['test', 'mock', 'cypress'],
|
||||||
updateTime: '2021-04-24T09:27:51.000Z',
|
updateTime: '2021-04-24T09:27:51.000Z',
|
||||||
updateUser: {
|
updateUser: null,
|
||||||
userName: 'test',
|
viewCount: 0,
|
||||||
displayName: 'Testy',
|
version: 2,
|
||||||
photo: '',
|
|
||||||
email: ''
|
|
||||||
},
|
|
||||||
createTime: '2021-04-24T09:27:51.000Z',
|
createTime: '2021-04-24T09:27:51.000Z',
|
||||||
editedBy: []
|
editedBy: [],
|
||||||
|
permissions: {
|
||||||
|
owner: null,
|
||||||
|
sharedToUsers: [],
|
||||||
|
sharedToGroups: []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
editedByAtPosition: []
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -163,7 +163,9 @@
|
||||||
"new": "New password",
|
"new": "New password",
|
||||||
"newAgain": "New password again",
|
"newAgain": "New password again",
|
||||||
"info": "Your new password should contain at least 6 characters.",
|
"info": "Your new password should contain at least 6 characters.",
|
||||||
"failed": "Changing your password failed. Check your old password and try again."
|
"failed": "Changing your password failed. Check your old password and try again.",
|
||||||
|
"successTitle": "Password changed",
|
||||||
|
"successText": "Your password has been changed successfully."
|
||||||
},
|
},
|
||||||
"changeDisplayNameFailed": "There was an error changing your display name.",
|
"changeDisplayNameFailed": "There was an error changing your display name.",
|
||||||
"accountManagement": "Account management",
|
"accountManagement": "Account management",
|
||||||
|
@ -178,6 +180,7 @@
|
||||||
"label": "Token label",
|
"label": "Token label",
|
||||||
"created": "created {{time}}",
|
"created": "created {{time}}",
|
||||||
"lastUsed": "last used {{time}}",
|
"lastUsed": "last used {{time}}",
|
||||||
|
"neverUsed": "never used",
|
||||||
"loadingFailed": "Fetching your access tokens has failed. Try reloading this page.",
|
"loadingFailed": "Fetching your access tokens has failed. Try reloading this page.",
|
||||||
"creationFailed": "Creating the access token failed.",
|
"creationFailed": "Creating the access token failed.",
|
||||||
"expiry": "Expiry date"
|
"expiry": "Expiry date"
|
||||||
|
@ -375,7 +378,7 @@
|
||||||
},
|
},
|
||||||
"documentInfo": {
|
"documentInfo": {
|
||||||
"title": "Document info",
|
"title": "Document info",
|
||||||
"created": "<0></0> created this note <1></1>",
|
"created": "This note was created <0></0>",
|
||||||
"edited": "<0></0> was the last editor <1></1>",
|
"edited": "<0></0> was the last editor <1></1>",
|
||||||
"usersContributed": "<0></0> users contributed to this document",
|
"usersContributed": "<0></0> users contributed to this document",
|
||||||
"revisions": "<0></0> revisions are saved",
|
"revisions": "<0></0> revisions are saved",
|
||||||
|
@ -393,6 +396,7 @@
|
||||||
"title": "Revisions",
|
"title": "Revisions",
|
||||||
"revertButton": "Revert",
|
"revertButton": "Revert",
|
||||||
"error": "An error occurred while fetching the revisions of this note.",
|
"error": "An error occurred while fetching the revisions of this note.",
|
||||||
|
"errorUser": "An error occurred while fetching the user information for this revision.",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"download": "Download selected revision"
|
"download": "Download selected revision"
|
||||||
},
|
},
|
||||||
|
@ -410,18 +414,25 @@
|
||||||
"title": "Permissions",
|
"title": "Permissions",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
"sharedWithUsers": "Shared with users",
|
"sharedWithUsers": "Shared with users",
|
||||||
"sharedWithGroups": "Also share with…",
|
"sharedWithGroups": "Shared with groups",
|
||||||
|
"sharedWithElse": "Shared with else...",
|
||||||
"editUser": "Change {{name}}'s permissions to view and edit",
|
"editUser": "Change {{name}}'s permissions to view and edit",
|
||||||
"viewOnlyUser": "Change {{name}}'s permissions to view only",
|
"viewOnlyUser": "Change {{name}}'s permissions to view only",
|
||||||
"removeUser": "Remove {{name}}'s permissions",
|
"removeUser": "Remove {{name}}'s permissions",
|
||||||
"addUser": "Add user",
|
"addUser": "Add user",
|
||||||
"editGroup": "Change permissions of group \"{{name}}\" to view & edit",
|
"editGroup": "Change permissions of group \"{{name}}\" to view & edit",
|
||||||
"viewOnlyGroup": "Change permissions of group \"{{name}}\" to view only",
|
"viewOnlyGroup": "Change permissions of group \"{{name}}\" to view only",
|
||||||
|
"removeGroup": "Remove permissions of group \"{{name}}\"",
|
||||||
"denyGroup": "Deny access to group \"{{name}}\"",
|
"denyGroup": "Deny access to group \"{{name}}\"",
|
||||||
"addGroup": "Add group",
|
"addGroup": "Add group",
|
||||||
"allUser": "Everyone",
|
"allUser": "Everyone",
|
||||||
"allLoggedInUser": "All logged-in users",
|
"allLoggedInUser": "All logged-in users",
|
||||||
"error": "An error occurred while fetching the user information of this note."
|
"error": "An error occurred while updating the permissions of this note.",
|
||||||
|
"ownerChange": {
|
||||||
|
"error": "There was an error changing the owner of this note.",
|
||||||
|
"placeholder": "Enter username of new note owner",
|
||||||
|
"button": "Change the owner of this note"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"shareLink": {
|
"shareLink": {
|
||||||
"title": "Share link",
|
"title": "Share link",
|
||||||
|
@ -499,9 +510,10 @@
|
||||||
"avatarOf": "avatar of '{{name}}'",
|
"avatarOf": "avatar of '{{name}}'",
|
||||||
"why": "Why?",
|
"why": "Why?",
|
||||||
"loading": "Loading ...",
|
"loading": "Loading ...",
|
||||||
"errorOccurred": "An error occurred",
|
|
||||||
"errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
|
"errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
|
||||||
"readForMoreInfo": "Read here for more information"
|
"errorOccurred": "An error occurred",
|
||||||
|
"readForMoreInfo": "Read here for more information",
|
||||||
|
"guestUser": "Guest user"
|
||||||
},
|
},
|
||||||
"copyOverlay": {
|
"copyOverlay": {
|
||||||
"error": "Error while copying!",
|
"error": "Error while copying!",
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
|
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
![HedgeDoc Screenshot](/mock-backend/public/screenshot.png)
|
![HedgeDoc Screenshot](/mock-public/screenshot.png)
|
||||||
|
|
||||||
[![Deployed using netlify](https://www.netlify.com/img/global/badges/netlify-color-accent.svg)](https://www.netlify.com)
|
[![Deployed using netlify](https://www.netlify.com/img/global/badges/netlify-color-accent.svg)](https://www.netlify.com)
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
#
|
||||||
|
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo 'Patch intro.md to include netlify banner.'
|
echo 'Patch intro.md to include netlify banner.'
|
||||||
cp netlify/intro.md public/mock-backend/public/intro.md
|
cp netlify/intro.md public/mock-public/intro.md
|
||||||
echo 'Patch motd.md to include privacy policy.'
|
echo 'Patch motd.md to include privacy policy.'
|
||||||
cp netlify/motd.md public/mock-backend/public/motd.md
|
cp netlify/motd.md public/mock-public/motd.md
|
||||||
echo 'Patch version.json to include git hash'
|
echo 'Patch version.json to include git hash'
|
||||||
jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json
|
jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json
|
||||||
mv src/_version.json src/version.json
|
mv src/_version.json src/version.json
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"lint": "eslint --max-warnings=0 --ext .ts,.tsx src",
|
"lint": "eslint --max-warnings=0 --ext .ts,.tsx src",
|
||||||
"lint:fix": "eslint --fix --ext .ts,.tsx src",
|
"lint:fix": "eslint --fix --ext .ts,.tsx src",
|
||||||
"start": "cross-env PORT=3001 next start",
|
"start": "cross-env PORT=3001 next start",
|
||||||
|
"start:mock": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=true next start",
|
||||||
"start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start",
|
"start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start",
|
||||||
"cy:open": "cypress open",
|
"cy:open": "cypress open",
|
||||||
"cy:run:chrome": "cypress run --browser chrome",
|
"cy:run:chrome": "cypress run --browser chrome",
|
||||||
|
@ -53,6 +54,7 @@
|
||||||
"copy-webpack-plugin": "10.2.4",
|
"copy-webpack-plugin": "10.2.4",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"d3-graphviz": "4.1.1",
|
"d3-graphviz": "4.1.1",
|
||||||
|
"deepmerge": "4.2.2",
|
||||||
"diff": "5.0.0",
|
"diff": "5.0.0",
|
||||||
"dompurify": "2.3.6",
|
"dompurify": "2.3.6",
|
||||||
"emoji-picker-element": "1.11.2",
|
"emoji-picker-element": "1.11.2",
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
{
|
|
||||||
"allowAnonymous": true,
|
|
||||||
"authProviders": {
|
|
||||||
"facebook": true,
|
|
||||||
"github": true,
|
|
||||||
"twitter": true,
|
|
||||||
"gitlab": true,
|
|
||||||
"dropbox": true,
|
|
||||||
"ldap": true,
|
|
||||||
"google": true,
|
|
||||||
"saml": true,
|
|
||||||
"oauth2": true,
|
|
||||||
"local": true
|
|
||||||
},
|
|
||||||
"allowRegister": true,
|
|
||||||
"branding": {
|
|
||||||
"name": "DEMO Corp",
|
|
||||||
"logo": "/mock-backend/public/img/demo.png"
|
|
||||||
},
|
|
||||||
"customAuthNames": {
|
|
||||||
"ldap": "FooBar",
|
|
||||||
"oauth2": "Olaf2",
|
|
||||||
"saml": "aufSAMLn.de"
|
|
||||||
},
|
|
||||||
"maxDocumentLength": 100000,
|
|
||||||
"useImageProxy": false,
|
|
||||||
"plantumlServer": "https://www.plantuml.com/plantuml",
|
|
||||||
"specialUrls": {
|
|
||||||
"privacy": "https://example.com/privacy",
|
|
||||||
"termsOfUse": "https://example.com/termsOfUse",
|
|
||||||
"imprint": "https://example.com/imprint"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"major": -1,
|
|
||||||
"minor": -1,
|
|
||||||
"patch": -1,
|
|
||||||
"commit": "mock"
|
|
||||||
},
|
|
||||||
"iframeCommunication": {
|
|
||||||
"editorOrigin": "http://localhost:3001/",
|
|
||||||
"rendererOrigin": "http://127.0.0.1:3001/"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"username": "mockUser",
|
|
||||||
"photo": "/mock-backend/public/img/avatar.png",
|
|
||||||
"displayName": "Test",
|
|
||||||
"email": "mock@hedgedoc.dev"
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"identifier": "29QLD0AmT-adevdOPECtqg",
|
|
||||||
"title": "",
|
|
||||||
"lastVisited": "2020-05-16T22:26:56.547Z",
|
|
||||||
"pinStatus": false,
|
|
||||||
"tags": [
|
|
||||||
"empty title",
|
|
||||||
"should be untitled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "slide-example",
|
|
||||||
"title": "Slide example",
|
|
||||||
"lastVisited": "2020-05-30T15:20:36.088Z",
|
|
||||||
"pinStatus": true,
|
|
||||||
"tags": [
|
|
||||||
"features",
|
|
||||||
"cool",
|
|
||||||
"updated"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "features",
|
|
||||||
"title": "Features",
|
|
||||||
"lastVisited": "2020-05-31T15:20:36.088Z",
|
|
||||||
"pinStatus": true,
|
|
||||||
"tags": [
|
|
||||||
"features",
|
|
||||||
"cool",
|
|
||||||
"updated"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "ODakLc2MQkyyFc_Xmb53sg",
|
|
||||||
"title": "HedgeDoc V2 API",
|
|
||||||
"lastVisited": "2020-05-25T19:48:14.025Z",
|
|
||||||
"pinStatus": false,
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "l8JuWxApTR6Fqa0LCrpnLg",
|
|
||||||
"title": "Community call - Let’s meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
|
|
||||||
"lastVisited": "2020-05-24T16:04:36.433Z",
|
|
||||||
"pinStatus": false,
|
|
||||||
"tags": [
|
|
||||||
"agenda",
|
|
||||||
"HedgeDoc community",
|
|
||||||
"community call"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"link": "/mock-backend/public/img/avatar.png"
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"timestamp": 1598390307,
|
|
||||||
"length": 2788,
|
|
||||||
"authors": ["dermolly", "mrdrogdrog"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": 1598389571,
|
|
||||||
"length": 2782,
|
|
||||||
"authors": ["dermolly", "mrdrogdrog", "emcrx"]
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"content": "---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags: hedgedoc, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=KgMpKsp23yY\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : hello --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is <back:cadetblue><size:18>displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n <u:red>This<\/u> is <color #118888>displayed<\/color>\n **<color purple>left of<\/color> <s:red>Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n <w:#FF33FF>This is hosted<\/w> by <img sourceforge.jpg>\nend note\n@enduml\n```\n\n",
|
|
||||||
"timestamp": 1598389571,
|
|
||||||
"authors": ["mrdrogdrog", "dermolly", "emcrx"]
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"content": "---\ntitle: Features\ndescription: Many more features, such wow!\nrobots: noindex\ntags: hedgedoc, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magnus aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetezur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam _et_ justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=zHAIuE5BQWk\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : bye --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is <back:cadetblue><size:18>displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n <u:red>This<\/u> is <color #118888>displayed<\/color>\n **<color purple>left of<\/color> <s:red>Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n <w:#FF33FF>This is hosted<\/w> by <img sourceforge.jpg>\nend note\n@enduml\n```\n\n",
|
|
||||||
"timestamp": 1598390307,
|
|
||||||
"authors": ["mrdrogdrog", "dermolly"]
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"content": "test123",
|
|
||||||
"metadata": {
|
|
||||||
"id": "ABC3",
|
|
||||||
"alias": "old",
|
|
||||||
"version": 1,
|
|
||||||
"viewCount": 0,
|
|
||||||
"updateTime": "2021-04-24T09:27:51.000Z",
|
|
||||||
"updateUser": {
|
|
||||||
"userName": "test",
|
|
||||||
"displayName": "Testy",
|
|
||||||
"photo": "",
|
|
||||||
"email": ""
|
|
||||||
},
|
|
||||||
"createTime": "2021-04-24T09:27:51.000Z",
|
|
||||||
"editedBy": []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"label": "Demo-App",
|
|
||||||
"keyId": "demo",
|
|
||||||
"createdAt": "2021-11-20T23:54:13+01:00",
|
|
||||||
"lastUsed": "2021-11-20T23:54:13+01:00",
|
|
||||||
"validUntil": "2022-11-20"
|
|
||||||
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "CLI @ Test-PC",
|
|
||||||
"keyId": "cli",
|
|
||||||
"createdAt": "2021-11-20T23:54:13+01:00",
|
|
||||||
"lastUsed": "2021-11-20T23:54:13+01:00",
|
|
||||||
"validUntil": "2021-11-20"
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"id": "dermolly",
|
|
||||||
"photo": "/mock-backend/public/img/avatar.png",
|
|
||||||
"name": "Philip",
|
|
||||||
"status": "ok",
|
|
||||||
"provider": "internal"
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"id": "emcrx",
|
|
||||||
"photo": "/mock-backend/public/img/avatar.png",
|
|
||||||
"name": "Erik",
|
|
||||||
"status": "ok",
|
|
||||||
"provider": "internal"
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"id": "mrdrogdrog",
|
|
||||||
"photo": "/mock-backend/public/img/avatar.png",
|
|
||||||
"name": "Tilman",
|
|
||||||
"status": "ok",
|
|
||||||
"provider": "internal"
|
|
||||||
}
|
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 3.9 MiB |
|
@ -2,4 +2,4 @@
|
||||||
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
|
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
![HedgeDoc Screenshot](/mock-backend/public/screenshot.png)
|
![HedgeDoc Screenshot](/mock-public/screenshot.png)
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
48
src/api/alias/index.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { Alias, NewAliasDto, PrimaryAliasDto } from './types'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an alias to an existing note.
|
||||||
|
* @param noteIdOrAlias The note id or an existing alias for a note.
|
||||||
|
* @param newAlias The new alias.
|
||||||
|
* @return Information about the newly created alias.
|
||||||
|
*/
|
||||||
|
export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise<Alias> => {
|
||||||
|
const response = await new PostApiRequestBuilder<Alias, NewAliasDto>('alias')
|
||||||
|
.withJsonBody({
|
||||||
|
noteIdOrAlias,
|
||||||
|
newAlias
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a given alias as the primary one for a note.
|
||||||
|
* The former primary alias should be marked as non-primary by the backend automatically.
|
||||||
|
* @param alias The alias to mark as primary for its corresponding note.
|
||||||
|
* @return The updated information about the alias.
|
||||||
|
*/
|
||||||
|
export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
|
||||||
|
const response = await new PutApiRequestBuilder<Alias, PrimaryAliasDto>('alias/' + alias)
|
||||||
|
.withJsonBody({
|
||||||
|
primaryAlias: true
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a given alias from its corresponding note.
|
||||||
|
* @param alias The alias to remove from its note.
|
||||||
|
*/
|
||||||
|
export const deleteAlias = async (alias: string): Promise<void> => {
|
||||||
|
await new DeleteApiRequestBuilder('alias/' + alias).sendRequest()
|
||||||
|
}
|
19
src/api/alias/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
export interface Alias {
|
||||||
|
name: string
|
||||||
|
primaryAlias: boolean
|
||||||
|
noteId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewAliasDto {
|
||||||
|
noteIdOrAlias: string
|
||||||
|
newAlias: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrimaryAliasDto {
|
||||||
|
primaryAlias: boolean
|
||||||
|
}
|
|
@ -1,34 +1,14 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
export const INTERACTIVE_LOGIN_METHODS = ['local', 'ldap']
|
|
||||||
|
|
||||||
export enum AuthError {
|
|
||||||
INVALID_CREDENTIALS = 'invalidCredentials',
|
|
||||||
LOGIN_DISABLED = 'loginDisabled',
|
|
||||||
OPENID_ERROR = 'openIdError',
|
|
||||||
OTHER = 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RegisterError {
|
|
||||||
USERNAME_EXISTING = 'usernameExisting',
|
|
||||||
REGISTRATION_DISABLED = 'registrationDisabled',
|
|
||||||
OTHER = 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to logout the current user.
|
* Requests to log out the current user.
|
||||||
* @throws Error if logout is not possible.
|
* @throws Error if logout is not possible.
|
||||||
*/
|
*/
|
||||||
export const doLogout = async (): Promise<void> => {
|
export const doLogout = async (): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'auth/logout', {
|
await new DeleteApiRequestBuilder('auth/logout').sendRequest()
|
||||||
...defaultFetchConfig,
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,28 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import type { LoginDto } from './types'
|
||||||
|
import { AuthError } from './types'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to login a user via LDAP credentials.
|
* Requests to login a user via LDAP credentials.
|
||||||
|
* @param provider The identifier of the LDAP provider with which to login.
|
||||||
* @param username The username with which to try the login.
|
* @param username The username with which to try the login.
|
||||||
* @param password The password of the user.
|
* @param password The password of the user.
|
||||||
|
* @throws {AuthError.INVALID_CREDENTIALS} if the LDAP provider denied the given credentials.
|
||||||
*/
|
*/
|
||||||
export const doLdapLogin = async (username: string, password: string): Promise<void> => {
|
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'auth/ldap', {
|
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
|
||||||
...defaultFetchConfig,
|
.withJsonBody({
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password
|
||||||
})
|
})
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
401: AuthError.INVALID_CREDENTIALS
|
||||||
})
|
})
|
||||||
|
.sendRequest()
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,31 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { ChangePasswordDto, LoginDto, RegisterDto } from './types'
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { AuthError, RegisterError } from './types'
|
||||||
import { AuthError, RegisterError } from './index'
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to do a local login with a provided username and password.
|
* Requests to do a local login with a provided username and password.
|
||||||
* @param username The username for which the login should be tried.
|
* @param username The username for which the login should be tried.
|
||||||
* @param password The password which should be used to login.
|
* @param password The password which should be used to log in.
|
||||||
* @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong.
|
* @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong.
|
||||||
* @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend.
|
* @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend.
|
||||||
*/
|
*/
|
||||||
export const doLocalLogin = async (username: string, password: string): Promise<void> => {
|
export const doLocalLogin = async (username: string, password: string): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'auth/local/login', {
|
await new PostApiRequestBuilder<void, LoginDto>('auth/local/login')
|
||||||
...defaultFetchConfig,
|
.withJsonBody({
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
username,
|
||||||
password
|
password
|
||||||
})
|
})
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: AuthError.LOGIN_DISABLED,
|
||||||
|
401: AuthError.INVALID_CREDENTIALS
|
||||||
})
|
})
|
||||||
|
.sendRequest()
|
||||||
if (response.status === 400) {
|
|
||||||
throw new Error(AuthError.LOGIN_DISABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
throw new Error(AuthError.INVALID_CREDENTIALS)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectResponseCode(response, 201)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,29 +33,21 @@ export const doLocalLogin = async (username: string, password: string): Promise<
|
||||||
* @param username The username of the new user.
|
* @param username The username of the new user.
|
||||||
* @param displayName The display name of the new user.
|
* @param displayName The display name of the new user.
|
||||||
* @param password The password of the new user.
|
* @param password The password of the new user.
|
||||||
* @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same user name.
|
* @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same username.
|
||||||
* @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend.
|
* @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend.
|
||||||
*/
|
*/
|
||||||
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
|
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'auth/local', {
|
await new PostApiRequestBuilder<void, RegisterDto>('auth/local')
|
||||||
...defaultFetchConfig,
|
.withJsonBody({
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
password
|
password
|
||||||
})
|
})
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: RegisterError.REGISTRATION_DISABLED,
|
||||||
|
409: RegisterError.USERNAME_EXISTING
|
||||||
})
|
})
|
||||||
|
.sendRequest()
|
||||||
if (response.status === 409) {
|
|
||||||
throw new Error(RegisterError.USERNAME_EXISTING)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 400) {
|
|
||||||
throw new Error(RegisterError.REGISTRATION_DISABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,22 +58,14 @@ export const doLocalRegister = async (username: string, displayName: string, pas
|
||||||
* @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend.
|
* @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend.
|
||||||
*/
|
*/
|
||||||
export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise<void> => {
|
export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'auth/local', {
|
await new PutApiRequestBuilder<void, ChangePasswordDto>('auth/local')
|
||||||
...defaultFetchConfig,
|
.withJsonBody({
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword
|
newPassword
|
||||||
})
|
})
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: AuthError.LOGIN_DISABLED,
|
||||||
|
401: AuthError.INVALID_CREDENTIALS
|
||||||
})
|
})
|
||||||
|
.sendRequest()
|
||||||
if (response.status === 401) {
|
|
||||||
throw new Error(AuthError.INVALID_CREDENTIALS)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 400) {
|
|
||||||
throw new Error(AuthError.LOGIN_DISABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
33
src/api/auth/types.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
export enum AuthError {
|
||||||
|
INVALID_CREDENTIALS = 'invalidCredentials',
|
||||||
|
LOGIN_DISABLED = 'loginDisabled',
|
||||||
|
OPENID_ERROR = 'openIdError',
|
||||||
|
OTHER = 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RegisterError {
|
||||||
|
USERNAME_EXISTING = 'usernameExisting',
|
||||||
|
REGISTRATION_DISABLED = 'registrationDisabled',
|
||||||
|
OTHER = 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginDto {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterDto {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordDto {
|
||||||
|
currentPassword: string
|
||||||
|
newPassword: string
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiRequestBuilder } from './api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct and execute a call to the HTTP API that contains a body payload.
|
||||||
|
*
|
||||||
|
* @param RequestBodyType The type of the request body if applicable.
|
||||||
|
*/
|
||||||
|
export abstract class ApiRequestBuilderWithBody<ResponseType, RequestBodyType> extends ApiRequestBuilder<ResponseType> {
|
||||||
|
/**
|
||||||
|
* Adds a body part to the API request. If this is called multiple times, only the body of the last invocation will be
|
||||||
|
* used during the execution of the request.
|
||||||
|
*
|
||||||
|
* @param bodyData The data to use as request body.
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
*/
|
||||||
|
withBody(bodyData: BodyInit): this {
|
||||||
|
this.requestBody = bodyData
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a JSON-encoded body part to the API request. This method will set the content-type header appropriately.
|
||||||
|
*
|
||||||
|
* @param bodyData The data to use as request body. Will get stringified to JSON.
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
* @see {withBody}
|
||||||
|
*/
|
||||||
|
withJsonBody(bodyData: RequestBodyType): this {
|
||||||
|
this.withHeader('Content-Type', 'application/json')
|
||||||
|
return this.withBody(JSON.stringify(bodyData))
|
||||||
|
}
|
||||||
|
}
|
115
src/api/common/api-request-builder/api-request-builder.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiUrl } from '../../../utils/api-url'
|
||||||
|
import deepmerge from 'deepmerge'
|
||||||
|
import { defaultConfig, defaultHeaders } from '../default-config'
|
||||||
|
import { ApiResponse } from '../api-response'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct and execute a call to the HTTP API.
|
||||||
|
*
|
||||||
|
* @param ResponseType The type of the response if applicable.
|
||||||
|
*/
|
||||||
|
export abstract class ApiRequestBuilder<ResponseType> {
|
||||||
|
private readonly targetUrl: string
|
||||||
|
private overrideExpectedResponseStatus: number | undefined
|
||||||
|
private customRequestOptions = defaultConfig
|
||||||
|
private customRequestHeaders = new Headers(defaultHeaders)
|
||||||
|
private customStatusCodeErrorMapping: Record<number, string> | undefined
|
||||||
|
protected requestBody: BodyInit | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a new API call with the default request options.
|
||||||
|
*
|
||||||
|
* @param endpoint The target endpoint without a leading slash.
|
||||||
|
*/
|
||||||
|
constructor(endpoint: string) {
|
||||||
|
this.targetUrl = apiUrl + endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async sendRequestAndVerifyResponse(
|
||||||
|
httpMethod: RequestInit['method'],
|
||||||
|
defaultExpectedStatus: number
|
||||||
|
): Promise<ApiResponse<ResponseType>> {
|
||||||
|
const response = await fetch(this.targetUrl, {
|
||||||
|
...this.customRequestOptions,
|
||||||
|
method: httpMethod,
|
||||||
|
headers: this.customRequestHeaders,
|
||||||
|
body: this.requestBody
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.customStatusCodeErrorMapping && this.customStatusCodeErrorMapping[response.status]) {
|
||||||
|
throw new Error(this.customStatusCodeErrorMapping[response.status])
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedStatus = this.overrideExpectedResponseStatus
|
||||||
|
? this.overrideExpectedResponseStatus
|
||||||
|
: defaultExpectedStatus
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
throw new Error(`Expected response status code ${expectedStatus} but received ${response.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an HTTP header to the API request. Previous headers with the same name will get overridden on subsequent calls
|
||||||
|
* with the same name.
|
||||||
|
*
|
||||||
|
* @param name The name of the HTTP header to add. Example: 'Content-Type'
|
||||||
|
* @param value The value of the HTTP header to add. Example: 'text/markdown'
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
*/
|
||||||
|
withHeader(name: string, value: string): this {
|
||||||
|
this.customRequestHeaders.set(name, value)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds custom request options for the underlying fetch request by merging them with the existing options.
|
||||||
|
*
|
||||||
|
* @param options The options to set for the fetch request.
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
*/
|
||||||
|
withCustomOptions(options: Partial<Omit<RequestInit, 'method' | 'headers' | 'body'>>): this {
|
||||||
|
this.customRequestOptions = deepmerge(this.customRequestOptions, options)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a mapping from response status codes to error messages. An error with the specified message will be thrown
|
||||||
|
* when the status code of the response matches one of the defined ones.
|
||||||
|
*
|
||||||
|
* @param mapping The mapping from response status codes to error messages.
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
*/
|
||||||
|
withStatusCodeErrorMapping(mapping: Record<number, string>): this {
|
||||||
|
this.customStatusCodeErrorMapping = mapping
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the expected status code of the response. Can be used to override the default expected status code.
|
||||||
|
* An error will be thrown when the status code of the response does not match the expected one.
|
||||||
|
*
|
||||||
|
* @param expectedCode The expected status code of the response.
|
||||||
|
* @return The API request instance itself for chaining.
|
||||||
|
*/
|
||||||
|
withExpectedStatusCode(expectedCode: number): this {
|
||||||
|
this.overrideExpectedResponseStatus = expectedCode
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the prepared API call as a GET request. A default status code of 200 is expected.
|
||||||
|
*
|
||||||
|
* @return The API response.
|
||||||
|
* @throws Error when the status code does not match the expected one or is defined as in the custom status code
|
||||||
|
* error mapping.
|
||||||
|
*/
|
||||||
|
abstract sendRequest(): Promise<ApiResponse<ResponseType>>
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
import { DeleteApiRequestBuilder } from './delete-api-request-builder'
|
||||||
|
|
||||||
|
describe('DeleteApiRequestBuilder', () => {
|
||||||
|
let originalFetch: typeof global['fetch']
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalFetch = global.fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
})
|
||||||
|
describe('sendRequest without body', () => {
|
||||||
|
it('without headers', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, { method: 'DELETE' })
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with overriding single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple different headers', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectedHeaders.append('test2', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test2', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with JSON body', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: expectedHeaders,
|
||||||
|
body: '{"test":true,"foo":"bar"}'
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder('test')
|
||||||
|
.withJsonBody({
|
||||||
|
test: true,
|
||||||
|
foo: 'bar'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with other body', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: 'HedgeDoc'
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with expected status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'DELETE' })
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom options', () => {
|
||||||
|
it('with one option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overriding single option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple options', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom error map', () => {
|
||||||
|
it('for valid status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 204, { method: 'DELETE' })
|
||||||
|
await new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 1', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 400, { method: 'DELETE' })
|
||||||
|
const request = new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('noooooo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 2', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 401, { method: 'DELETE' })
|
||||||
|
const request = new DeleteApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('not you!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { ApiResponse } from '../api-response'
|
||||||
|
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct a DELETE request to the API.
|
||||||
|
*
|
||||||
|
* @param ResponseType The type of the expected response. Defaults to no response body.
|
||||||
|
* @param RequestBodyType The type of the request body. Defaults to no request body.
|
||||||
|
* @see {ApiRequestBuilder}
|
||||||
|
*/
|
||||||
|
export class DeleteApiRequestBuilder<ResponseType = void, RequestBodyType = unknown> extends ApiRequestBuilderWithBody<
|
||||||
|
ResponseType,
|
||||||
|
RequestBodyType
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* @see {ApiRequestBuilder#sendRequest}
|
||||||
|
*/
|
||||||
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
|
return this.sendRequestAndVerifyResponse('DELETE', 204)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
import { GetApiRequestBuilder } from './get-api-request-builder'
|
||||||
|
|
||||||
|
describe('GetApiRequestBuilder', () => {
|
||||||
|
let originalFetch: typeof global['fetch']
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalFetch = global.fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest', () => {
|
||||||
|
it('without headers', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
|
||||||
|
await new GetApiRequestBuilder<string>('test').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test').withHeader('test', 'true').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with overriding single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple different headers', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectedHeaders.append('test2', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test2', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with expected status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
|
||||||
|
await new GetApiRequestBuilder<string>('test').withExpectedStatusCode(200).sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom options', () => {
|
||||||
|
it('with one option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overriding single option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple options', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom error map', () => {
|
||||||
|
it('for valid status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
|
||||||
|
await new GetApiRequestBuilder<string>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 1', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 400, { method: 'GET' })
|
||||||
|
const request = new GetApiRequestBuilder<string>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('noooooo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 2', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 401, { method: 'GET' })
|
||||||
|
const request = new GetApiRequestBuilder<string>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('not you!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiRequestBuilder } from './api-request-builder'
|
||||||
|
import type { ApiResponse } from '../api-response'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct a GET request to the API.
|
||||||
|
*
|
||||||
|
* @param ResponseType The type of the expected response.
|
||||||
|
* @see {ApiRequestBuilder}
|
||||||
|
*/
|
||||||
|
export class GetApiRequestBuilder<ResponseType> extends ApiRequestBuilder<ResponseType> {
|
||||||
|
/**
|
||||||
|
* @see {ApiRequestBuilder#sendRequest}
|
||||||
|
*/
|
||||||
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
|
return this.sendRequestAndVerifyResponse('GET', 200)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PostApiRequestBuilder } from './post-api-request-builder'
|
||||||
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
|
||||||
|
describe('PostApiRequestBuilder', () => {
|
||||||
|
let originalFetch: typeof global['fetch']
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalFetch = global.fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest without body', () => {
|
||||||
|
it('without headers', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, { method: 'POST' })
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with overriding single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple different headers', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectedHeaders.append('test2', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test2', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with JSON body', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: expectedHeaders,
|
||||||
|
body: '{"test":true,"foo":"bar"}'
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder('test')
|
||||||
|
.withJsonBody({
|
||||||
|
test: true,
|
||||||
|
foo: 'bar'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with other body', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'HedgeDoc'
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with expected status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'POST' })
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom options', () => {
|
||||||
|
it('with one option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overriding single option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple options', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom error map', () => {
|
||||||
|
it('for valid status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 201, { method: 'POST' })
|
||||||
|
await new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 1', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 400, { method: 'POST' })
|
||||||
|
const request = new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('noooooo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 2', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 401, { method: 'POST' })
|
||||||
|
const request = new PostApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('not you!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { ApiResponse } from '../api-response'
|
||||||
|
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct a POST request to the API.
|
||||||
|
*
|
||||||
|
* @param ResponseType The type of the expected response.
|
||||||
|
* @param RequestBodyType The type of the request body
|
||||||
|
* @see {ApiRequestBuilder}
|
||||||
|
*/
|
||||||
|
export class PostApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
|
||||||
|
ResponseType,
|
||||||
|
RequestBodyType
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* @see {ApiRequestBuilder#sendRequest}
|
||||||
|
*/
|
||||||
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
|
return this.sendRequestAndVerifyResponse('POST', 201)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
import { PutApiRequestBuilder } from './put-api-request-builder'
|
||||||
|
|
||||||
|
describe('PutApiRequestBuilder', () => {
|
||||||
|
let originalFetch: typeof global['fetch']
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalFetch = global.fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest without body', () => {
|
||||||
|
it('without headers', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with overriding single header', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple different headers', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('test', 'true')
|
||||||
|
expectedHeaders.append('test2', 'false')
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: expectedHeaders
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withHeader('test', 'true')
|
||||||
|
.withHeader('test2', 'false')
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with JSON body', async () => {
|
||||||
|
const expectedHeaders = new Headers()
|
||||||
|
expectedHeaders.append('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: expectedHeaders,
|
||||||
|
body: '{"test":true,"foo":"bar"}'
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder('test')
|
||||||
|
.withJsonBody({
|
||||||
|
test: true,
|
||||||
|
foo: 'bar'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with other body', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: 'HedgeDoc'
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendRequest with expected status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom options', () => {
|
||||||
|
it('with one option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overriding single option', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with multiple options', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, {
|
||||||
|
method: 'PUT',
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withCustomOptions({
|
||||||
|
cache: 'force-cache',
|
||||||
|
integrity: 'test'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendRequest with custom error map', () => {
|
||||||
|
it('for valid status code', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
|
||||||
|
await new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 1', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 400, { method: 'PUT' })
|
||||||
|
const request = new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('noooooo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for invalid status code 2', async () => {
|
||||||
|
expectFetch('/api/mock-backend/private/test', 401, { method: 'PUT' })
|
||||||
|
const request = new PutApiRequestBuilder<string, undefined>('test')
|
||||||
|
.withStatusCodeErrorMapping({
|
||||||
|
400: 'noooooo',
|
||||||
|
401: 'not you!'
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
await expect(request).rejects.toThrow('not you!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { ApiResponse } from '../api-response'
|
||||||
|
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to construct a PUT request to the API.
|
||||||
|
*
|
||||||
|
* @param ResponseType The type of the expected response.
|
||||||
|
* @param RequestBodyType The type of the request body
|
||||||
|
* @see {ApiRequestBuilder}
|
||||||
|
*/
|
||||||
|
export class PutApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
|
||||||
|
ResponseType,
|
||||||
|
RequestBodyType
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* @see {ApiRequestBuilder#sendRequest}
|
||||||
|
*/
|
||||||
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
|
return this.sendRequestAndVerifyResponse('PUT', 200)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defaultConfig } from '../../default-config'
|
||||||
|
import { Mock } from 'ts-mockery'
|
||||||
|
|
||||||
|
export const expectFetch = (expectedUrl: string, expectedStatusCode: number, expectedOptions: RequestInit): void => {
|
||||||
|
global.fetch = jest.fn((fetchUrl: RequestInfo, fetchOptions?: RequestInit): Promise<Response> => {
|
||||||
|
expect(fetchUrl).toEqual(expectedUrl)
|
||||||
|
expect(fetchOptions).toStrictEqual({
|
||||||
|
...defaultConfig,
|
||||||
|
body: undefined,
|
||||||
|
headers: new Headers(),
|
||||||
|
...expectedOptions
|
||||||
|
})
|
||||||
|
return Promise.resolve(
|
||||||
|
Mock.of<Response>({
|
||||||
|
status: expectedStatusCode
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
55
src/api/common/api-response.test.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Mock } from 'ts-mockery'
|
||||||
|
import { ApiResponse } from './api-response'
|
||||||
|
|
||||||
|
describe('ApiResponse', () => {
|
||||||
|
it('getResponse returns input response', () => {
|
||||||
|
const mockResponse = Mock.of<Response>()
|
||||||
|
const responseObj = new ApiResponse(mockResponse)
|
||||||
|
expect(responseObj.getResponse()).toEqual(mockResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asBlob', async () => {
|
||||||
|
const mockBlob = Mock.of<Blob>()
|
||||||
|
const mockResponse = Mock.of<Response>({
|
||||||
|
blob(): Promise<Blob> {
|
||||||
|
return Promise.resolve(mockBlob)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const responseObj = new ApiResponse(mockResponse)
|
||||||
|
await expect(responseObj.asBlob()).resolves.toEqual(mockBlob)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('asParsedJsonObject with', () => {
|
||||||
|
it('invalid header', async () => {
|
||||||
|
const mockHeaders = new Headers()
|
||||||
|
mockHeaders.set('Content-Type', 'text/invalid')
|
||||||
|
const mockResponse = Mock.of<Response>({
|
||||||
|
headers: mockHeaders
|
||||||
|
})
|
||||||
|
const responseObj = new ApiResponse(mockResponse)
|
||||||
|
await expect(responseObj.asParsedJsonObject()).rejects.toThrow('Response body does not seem to be JSON encoded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid header', async () => {
|
||||||
|
const mockHeaders = new Headers()
|
||||||
|
mockHeaders.set('Content-Type', 'application/json')
|
||||||
|
const mockBody = {
|
||||||
|
Hedgy: '🦔'
|
||||||
|
}
|
||||||
|
const mockResponse = Mock.of<Response>({
|
||||||
|
headers: mockHeaders,
|
||||||
|
json(): Promise<typeof mockBody> {
|
||||||
|
return Promise.resolve(mockBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const responseObj = new ApiResponse(mockResponse)
|
||||||
|
await expect(responseObj.asParsedJsonObject()).resolves.toEqual(mockBody)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
53
src/api/common/api-response.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that represents the response of an {@link ApiRequestBuilder}.
|
||||||
|
*/
|
||||||
|
export class ApiResponse<ResponseType> {
|
||||||
|
private readonly response: Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a new API response instance based on an HTTP response.
|
||||||
|
* @param response The HTTP response from the fetch call.
|
||||||
|
*/
|
||||||
|
constructor(response: Response) {
|
||||||
|
this.response = response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw response from the fetch call.
|
||||||
|
*
|
||||||
|
* @return The response from the fetch call.
|
||||||
|
*/
|
||||||
|
getResponse(): Response {
|
||||||
|
return this.response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded.
|
||||||
|
*
|
||||||
|
* @return The parsed JSON response.
|
||||||
|
* @throws Error if the response is not JSON encoded.
|
||||||
|
*/
|
||||||
|
async asParsedJsonObject(): Promise<ResponseType> {
|
||||||
|
if (!this.response.headers.get('Content-Type')?.startsWith('application/json')) {
|
||||||
|
throw new Error('Response body does not seem to be JSON encoded.')
|
||||||
|
}
|
||||||
|
// TODO Responses should better be type validated
|
||||||
|
// see https://github.com/hedgedoc/react-client/issues/1219
|
||||||
|
return (await this.response.json()) as ResponseType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the response as a Blob.
|
||||||
|
*
|
||||||
|
* @return The response body as a blob.
|
||||||
|
*/
|
||||||
|
async asBlob(): Promise<Blob> {
|
||||||
|
return await this.response.blob()
|
||||||
|
}
|
||||||
|
}
|
15
src/api/common/default-config.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const defaultHeaders: HeadersInit = {}
|
||||||
|
|
||||||
|
export const defaultConfig: Partial<RequestInit> = {
|
||||||
|
mode: 'cors',
|
||||||
|
cache: 'no-cache',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
redirect: 'follow',
|
||||||
|
referrerPolicy: 'no-referrer'
|
||||||
|
}
|
|
@ -1,16 +1,17 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
|
||||||
import type { Config } from './types'
|
import type { Config } from './types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the frontend config from the backend.
|
||||||
|
* @return The frontend config.
|
||||||
|
*/
|
||||||
export const getConfig = async (): Promise<Config> => {
|
export const getConfig = async (): Promise<Config> => {
|
||||||
const response = await fetch(getApiUrl() + 'config', {
|
const response = await new GetApiRequestBuilder<Config>('config').sendRequest()
|
||||||
...defaultFetchConfig
|
return response.asParsedJsonObject()
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
return (await response.json()) as Promise<Config>
|
|
||||||
}
|
}
|
||||||
|
|
62
src/api/config/types.d.ts
vendored
|
@ -1,62 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
allowAnonymous: boolean
|
|
||||||
allowRegister: boolean
|
|
||||||
authProviders: AuthProvidersState
|
|
||||||
branding: BrandingConfig
|
|
||||||
customAuthNames: CustomAuthNames
|
|
||||||
useImageProxy: boolean
|
|
||||||
specialUrls: SpecialUrls
|
|
||||||
version: BackendVersion
|
|
||||||
plantumlServer: string | null
|
|
||||||
maxDocumentLength: number
|
|
||||||
iframeCommunication: iframeCommunicationConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface iframeCommunicationConfig {
|
|
||||||
editorOrigin: string
|
|
||||||
rendererOrigin: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrandingConfig {
|
|
||||||
name: string
|
|
||||||
logo: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackendVersion {
|
|
||||||
major: number
|
|
||||||
minor: number
|
|
||||||
patch: number
|
|
||||||
preRelease?: string
|
|
||||||
commit?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthProvidersState {
|
|
||||||
facebook: boolean
|
|
||||||
github: boolean
|
|
||||||
twitter: boolean
|
|
||||||
gitlab: boolean
|
|
||||||
dropbox: boolean
|
|
||||||
ldap: boolean
|
|
||||||
google: boolean
|
|
||||||
saml: boolean
|
|
||||||
oauth2: boolean
|
|
||||||
local: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomAuthNames {
|
|
||||||
ldap: string
|
|
||||||
oauth2: string
|
|
||||||
saml: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpecialUrls {
|
|
||||||
privacy?: string
|
|
||||||
termsOfUse?: string
|
|
||||||
imprint?: string
|
|
||||||
}
|
|
92
src/api/config/types.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
allowAnonymous: boolean
|
||||||
|
allowRegister: boolean
|
||||||
|
authProviders: AuthProvider[]
|
||||||
|
branding: BrandingConfig
|
||||||
|
useImageProxy: boolean
|
||||||
|
specialUrls: SpecialUrls
|
||||||
|
version: BackendVersion
|
||||||
|
plantumlServer?: string
|
||||||
|
maxDocumentLength: number
|
||||||
|
iframeCommunication: iframeCommunicationConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuthProviderType {
|
||||||
|
DROPBOX = 'dropbox',
|
||||||
|
FACEBOOK = 'facebook',
|
||||||
|
GITHUB = 'github',
|
||||||
|
GOOGLE = 'google',
|
||||||
|
TWITTER = 'twitter',
|
||||||
|
GITLAB = 'gitlab',
|
||||||
|
OAUTH2 = 'oauth2',
|
||||||
|
LDAP = 'ldap',
|
||||||
|
SAML = 'saml',
|
||||||
|
LOCAL = 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthProviderTypeWithCustomName =
|
||||||
|
| AuthProviderType.GITLAB
|
||||||
|
| AuthProviderType.OAUTH2
|
||||||
|
| AuthProviderType.LDAP
|
||||||
|
| AuthProviderType.SAML
|
||||||
|
|
||||||
|
export type AuthProviderTypeWithoutCustomName =
|
||||||
|
| AuthProviderType.DROPBOX
|
||||||
|
| AuthProviderType.FACEBOOK
|
||||||
|
| AuthProviderType.GITHUB
|
||||||
|
| AuthProviderType.GOOGLE
|
||||||
|
| AuthProviderType.TWITTER
|
||||||
|
| AuthProviderType.LOCAL
|
||||||
|
|
||||||
|
export const authProviderTypeOneClick = [
|
||||||
|
AuthProviderType.DROPBOX,
|
||||||
|
AuthProviderType.FACEBOOK,
|
||||||
|
AuthProviderType.GITHUB,
|
||||||
|
AuthProviderType.GITLAB,
|
||||||
|
AuthProviderType.GOOGLE,
|
||||||
|
AuthProviderType.OAUTH2,
|
||||||
|
AuthProviderType.SAML,
|
||||||
|
AuthProviderType.TWITTER
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface AuthProviderWithCustomName {
|
||||||
|
type: AuthProviderTypeWithCustomName
|
||||||
|
identifier: string
|
||||||
|
providerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthProviderWithoutCustomName {
|
||||||
|
type: AuthProviderTypeWithoutCustomName
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthProvider = AuthProviderWithCustomName | AuthProviderWithoutCustomName
|
||||||
|
|
||||||
|
export interface iframeCommunicationConfig {
|
||||||
|
editorOrigin: string
|
||||||
|
rendererOrigin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandingConfig {
|
||||||
|
name?: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendVersion {
|
||||||
|
major: number
|
||||||
|
minor: number
|
||||||
|
patch: number
|
||||||
|
preRelease?: string
|
||||||
|
commit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecialUrls {
|
||||||
|
privacy?: string
|
||||||
|
termsOfUse?: string
|
||||||
|
imprint?: string
|
||||||
|
}
|
18
src/api/group/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GroupInfo } from './types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves information about a group with a given name.
|
||||||
|
* @param groupName The name of the group.
|
||||||
|
* @return Information about the group.
|
||||||
|
*/
|
||||||
|
export const getGroup = async (groupName: string): Promise<GroupInfo> => {
|
||||||
|
const response = await new GetApiRequestBuilder<GroupInfo>('groups/' + groupName).sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GroupInfoDto {
|
export interface GroupInfo {
|
||||||
name: string
|
name: string
|
||||||
displayName: string
|
displayName: string
|
||||||
special: boolean
|
special: boolean
|
|
@ -3,32 +3,20 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { HistoryEntry, HistoryEntryPutDto, HistoryEntryWithOrigin } from './types'
|
||||||
|
import { HistoryEntryOrigin } from './types'
|
||||||
|
|
||||||
import type { HistoryEntry } from '../../redux/history/types'
|
export const addRemoteOriginToHistoryEntry = (entryDto: HistoryEntry): HistoryEntryWithOrigin => {
|
||||||
import { HistoryEntryOrigin } from '../../redux/history/types'
|
|
||||||
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
|
||||||
|
|
||||||
export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => {
|
|
||||||
return {
|
return {
|
||||||
origin: HistoryEntryOrigin.REMOTE,
|
...entryDto,
|
||||||
title: entryDto.title,
|
origin: HistoryEntryOrigin.REMOTE
|
||||||
pinStatus: entryDto.pinStatus,
|
|
||||||
identifier: entryDto.identifier,
|
|
||||||
tags: entryDto.tags,
|
|
||||||
lastVisited: entryDto.lastVisited
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
|
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
|
||||||
return {
|
return {
|
||||||
pinStatus: entry.pinStatus,
|
pinStatus: entry.pinStatus,
|
||||||
lastVisited: entry.lastVisited,
|
lastVisitedAt: entry.lastVisitedAt,
|
||||||
note: entry.identifier
|
note: entry.identifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => {
|
|
||||||
return {
|
|
||||||
pinStatus: entry.pinStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,50 +1,59 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { ChangePinStatusDto, HistoryEntry, HistoryEntryPutDto } from './types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
/**
|
||||||
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
* Fetches the remote history for the user from the server.
|
||||||
|
* @return The remote history entries of the user.
|
||||||
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
|
*/
|
||||||
const response = await fetch(getApiUrl() + 'me/history', {
|
export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
||||||
...defaultFetchConfig
|
const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history').sendRequest()
|
||||||
})
|
return response.asParsedJsonObject()
|
||||||
expectResponseCode(response)
|
|
||||||
return (await response.json()) as Promise<HistoryEntryDto[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postHistory = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
/**
|
||||||
const response = await fetch(getApiUrl() + 'me/history', {
|
* Replaces the remote history of the user with the given history entries.
|
||||||
...defaultFetchConfig,
|
* @param entries The history entries to store remotely.
|
||||||
method: 'POST',
|
*/
|
||||||
body: JSON.stringify(entries)
|
export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
||||||
})
|
await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history').withJsonBody(entries).sendRequest()
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise<void> => {
|
/**
|
||||||
const response = await fetch(getApiUrl() + 'me/history/' + noteId, {
|
* Updates a remote history entry's pin state.
|
||||||
...defaultFetchConfig,
|
* @param noteIdOrAlias The note id for which to update the pinning state.
|
||||||
method: 'PUT',
|
* @param pinStatus True when the note should be pinned, false otherwise.
|
||||||
body: JSON.stringify(entry)
|
*/
|
||||||
|
export const updateRemoteHistoryEntryPinStatus = async (
|
||||||
|
noteIdOrAlias: string,
|
||||||
|
pinStatus: boolean
|
||||||
|
): Promise<HistoryEntry> => {
|
||||||
|
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>('me/history/' + noteIdOrAlias)
|
||||||
|
.withJsonBody({
|
||||||
|
pinStatus
|
||||||
})
|
})
|
||||||
expectResponseCode(response)
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteHistoryEntry = async (noteId: string): Promise<void> => {
|
/**
|
||||||
const response = await fetch(getApiUrl() + 'me/history/' + noteId, {
|
* Deletes a remote history entry.
|
||||||
...defaultFetchConfig,
|
* @param noteIdOrAlias The note id or alias of the history entry to remove.
|
||||||
method: 'DELETE'
|
*/
|
||||||
})
|
export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
expectResponseCode(response)
|
await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteHistory = async (): Promise<void> => {
|
/**
|
||||||
const response = await fetch(getApiUrl() + 'me/history', {
|
* Deletes the complete remote history.
|
||||||
...defaultFetchConfig,
|
*/
|
||||||
method: 'DELETE'
|
export const deleteRemoteHistory = async (): Promise<void> => {
|
||||||
})
|
await new DeleteApiRequestBuilder('me/history').sendRequest()
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
23
src/api/history/types.d.ts
vendored
|
@ -1,23 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface HistoryEntryPutDto {
|
|
||||||
note: string
|
|
||||||
pinStatus: boolean
|
|
||||||
lastVisited: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntryUpdateDto {
|
|
||||||
pinStatus: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntryDto {
|
|
||||||
identifier: string
|
|
||||||
title: string
|
|
||||||
lastVisited: string
|
|
||||||
tags: string[]
|
|
||||||
pinStatus: boolean
|
|
||||||
}
|
|
31
src/api/history/types.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
export enum HistoryEntryOrigin {
|
||||||
|
LOCAL = 'local',
|
||||||
|
REMOTE = 'remote'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntryPutDto {
|
||||||
|
note: string
|
||||||
|
pinStatus: boolean
|
||||||
|
lastVisitedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
identifier: string
|
||||||
|
title: string
|
||||||
|
lastVisitedAt: string
|
||||||
|
tags: string[]
|
||||||
|
pinStatus: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntryWithOrigin extends HistoryEntry {
|
||||||
|
origin: HistoryEntryOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePinStatusDto {
|
||||||
|
pinStatus: boolean
|
||||||
|
}
|
|
@ -1,43 +1,48 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { MediaUpload } from '../media/types'
|
||||||
import type { UserInfoDto } from '../users/types'
|
import type { ChangeDisplayNameDto, LoginUserInfo } from './types'
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
import { isMockMode } from '../../utils/test-modes'
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns metadata about the currently signed-in user from the API.
|
* Returns metadata about the currently signed-in user from the API.
|
||||||
* @throws Error when the user is not signed-in.
|
* @throws Error when the user is not signed-in.
|
||||||
* @return The user metadata.
|
* @return The user metadata.
|
||||||
*/
|
*/
|
||||||
export const getMe = async (): Promise<UserInfoDto> => {
|
export const getMe = async (): Promise<LoginUserInfo> => {
|
||||||
const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, {
|
const response = await new GetApiRequestBuilder<LoginUserInfo>('me').sendRequest()
|
||||||
...defaultFetchConfig
|
return response.asParsedJsonObject()
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
return (await response.json()) as UserInfoDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateDisplayName = async (displayName: string): Promise<void> => {
|
|
||||||
const response = await fetch(getApiUrl() + 'me', {
|
|
||||||
...defaultFetchConfig,
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: displayName
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the current user from the server.
|
||||||
|
*/
|
||||||
export const deleteUser = async (): Promise<void> => {
|
export const deleteUser = async (): Promise<void> => {
|
||||||
const response = await fetch(getApiUrl() + 'me', {
|
await new DeleteApiRequestBuilder('me').sendRequest()
|
||||||
...defaultFetchConfig,
|
}
|
||||||
method: 'DELETE'
|
|
||||||
})
|
/**
|
||||||
|
* Changes the display name of the current user.
|
||||||
expectResponseCode(response)
|
* @param displayName The new display name to set.
|
||||||
|
*/
|
||||||
|
export const updateDisplayName = async (displayName: string): Promise<void> => {
|
||||||
|
await new PostApiRequestBuilder<void, ChangeDisplayNameDto>('me/profile')
|
||||||
|
.withJsonBody({
|
||||||
|
displayName
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of media belonging to the user.
|
||||||
|
* @return List of media object information.
|
||||||
|
*/
|
||||||
|
export const getMyMedia = async (): Promise<MediaUpload[]> => {
|
||||||
|
const response = await new GetApiRequestBuilder<MediaUpload[]>('me/media').sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
15
src/api/me/types.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { UserInfo } from '../users/types'
|
||||||
|
|
||||||
|
export interface LoginUserInfo extends UserInfo {
|
||||||
|
authProvider: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeDisplayNameDto {
|
||||||
|
displayName: string
|
||||||
|
}
|
|
@ -1,49 +1,47 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { ImageProxyRequestDto, ImageProxyResponse, MediaUpload } from './types'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
import { isMockMode, isTestMode } from '../../utils/test-modes'
|
/**
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
* Requests an image-proxy URL from the backend for a given image URL.
|
||||||
|
* @param imageUrl The image URL which should be proxied.
|
||||||
export interface ImageProxyResponse {
|
* @return The proxy URL for the image.
|
||||||
src: string
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
|
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
|
||||||
const response = await fetch(getApiUrl() + 'media/proxy', {
|
const response = await new PostApiRequestBuilder<ImageProxyResponse, ImageProxyRequestDto>('media/proxy')
|
||||||
...defaultFetchConfig,
|
.withJsonBody({
|
||||||
method: 'POST',
|
url: imageUrl
|
||||||
body: JSON.stringify({
|
|
||||||
src: imageUrl
|
|
||||||
})
|
})
|
||||||
})
|
.sendRequest()
|
||||||
expectResponseCode(response)
|
return response.asParsedJsonObject()
|
||||||
return (await response.json()) as Promise<ImageProxyResponse>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadedMedia {
|
/**
|
||||||
link: string
|
* Uploads a media file to the backend.
|
||||||
|
* @param noteIdOrAlias The id or alias of the note from which the media is uploaded.
|
||||||
|
* @param media The binary media content.
|
||||||
|
* @return The URL of the uploaded media object.
|
||||||
|
*/
|
||||||
|
export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<MediaUpload> => {
|
||||||
|
const postData = new FormData()
|
||||||
|
postData.append('file', media)
|
||||||
|
const response = await new PostApiRequestBuilder<MediaUpload, void>('media')
|
||||||
|
.withHeader('Content-Type', 'multipart/form-data')
|
||||||
|
.withHeader('HedgeDoc-Note', noteIdOrAlias)
|
||||||
|
.withBody(postData)
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadFile = async (noteId: string, media: Blob): Promise<UploadedMedia> => {
|
/**
|
||||||
const response = await fetch(`${getApiUrl()}media/upload${isMockMode() ? '-post' : ''}`, {
|
* Deletes some uploaded media object.
|
||||||
...defaultFetchConfig,
|
* @param mediaId The identifier of the media object to delete.
|
||||||
headers: {
|
*/
|
||||||
'Content-Type': media.type,
|
export const deleteUploadedMedia = async (mediaId: string): Promise<void> => {
|
||||||
'HedgeDoc-Note': noteId
|
await new DeleteApiRequestBuilder('media/' + mediaId).sendRequest()
|
||||||
},
|
|
||||||
method: isMockMode() ? 'GET' : 'POST',
|
|
||||||
body: isMockMode() ? undefined : media
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isMockMode() && !isTestMode()) {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 3000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
expectResponseCode(response, isMockMode() ? 200 : 201)
|
|
||||||
return (await response.json()) as Promise<UploadedMedia>
|
|
||||||
}
|
}
|
||||||
|
|
19
src/api/media/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
export interface MediaUpload {
|
||||||
|
url: string
|
||||||
|
noteId: string | null
|
||||||
|
createdAt: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageProxyResponse {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageProxyRequestDto {
|
||||||
|
url: string
|
||||||
|
}
|
|
@ -1,27 +1,65 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { Note } from './types'
|
||||||
|
import type { MediaUpload } from '../media/types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
/**
|
||||||
import type { NoteDto } from './types'
|
* Retrieves the content and metadata about the specified note.
|
||||||
import { isMockMode } from '../../utils/test-modes'
|
* @param noteIdOrAlias The id or alias of the note.
|
||||||
|
* @return Content and metadata of the specified note.
|
||||||
export const getNote = async (noteId: string): Promise<NoteDto> => {
|
*/
|
||||||
// The "-get" suffix is necessary, because in our mock api (filesystem) the note id might already be a folder.
|
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
||||||
// TODO: [mrdrogdrog] replace -get with actual api route as soon as api backend is ready.
|
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
|
||||||
const response = await fetch(getApiUrl() + `notes/${noteId}${isMockMode() ? '-get' : ''}`, {
|
return response.asParsedJsonObject()
|
||||||
...defaultFetchConfig
|
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
return (await response.json()) as Promise<NoteDto>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteNote = async (noteId: string): Promise<void> => {
|
/**
|
||||||
const response = await fetch(getApiUrl() + `notes/${noteId}`, {
|
* Returns a list of media objects associated with the specified note.
|
||||||
...defaultFetchConfig,
|
* @param noteIdOrAlias The id or alias of the note.
|
||||||
method: 'DELETE'
|
* @return List of media object metadata associated with specified note.
|
||||||
})
|
*/
|
||||||
expectResponseCode(response)
|
export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUpload[]> => {
|
||||||
|
const response = await new GetApiRequestBuilder<MediaUpload[]>(`notes/${noteIdOrAlias}/media`).sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new note with a given markdown content.
|
||||||
|
* @param markdown The content of the new note.
|
||||||
|
* @return Content and metadata of the new note.
|
||||||
|
*/
|
||||||
|
export const createNote = async (markdown: string): Promise<Note> => {
|
||||||
|
const response = await new PostApiRequestBuilder<Note, void>('notes')
|
||||||
|
.withHeader('Content-Type', 'text/markdown')
|
||||||
|
.withBody(markdown)
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new note with a given markdown content and a defined primary alias.
|
||||||
|
* @param markdown The content of the new note.
|
||||||
|
* @param primaryAlias The primary alias of the new note.
|
||||||
|
* @return Content and metadata of the new note.
|
||||||
|
*/
|
||||||
|
export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: string): Promise<Note> => {
|
||||||
|
const response = await new PostApiRequestBuilder<Note, void>('notes/' + primaryAlias)
|
||||||
|
.withHeader('Content-Type', 'text/markdown')
|
||||||
|
.withBody(markdown)
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the specified note.
|
||||||
|
* @param noteIdOrAlias The id or alias of the note to delete.
|
||||||
|
*/
|
||||||
|
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
|
await new DeleteApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest()
|
||||||
}
|
}
|
||||||
|
|
53
src/api/notes/types.d.ts
vendored
|
@ -1,53 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { UserInfoDto } from '../users/types'
|
|
||||||
import type { GroupInfoDto } from '../group/types'
|
|
||||||
|
|
||||||
export interface NoteDto {
|
|
||||||
content: string
|
|
||||||
metadata: NoteMetadataDto
|
|
||||||
editedByAtPosition: NoteAuthorshipDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteMetadataDto {
|
|
||||||
id: string
|
|
||||||
alias: string
|
|
||||||
version: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
tags: string[]
|
|
||||||
updateTime: string
|
|
||||||
updateUser: UserInfoDto
|
|
||||||
viewCount: number
|
|
||||||
createTime: string
|
|
||||||
editedBy: string[]
|
|
||||||
permissions: NotePermissionsDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteAuthorshipDto {
|
|
||||||
userName: string
|
|
||||||
startPos: number
|
|
||||||
endPos: number
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotePermissionsDto {
|
|
||||||
owner: UserInfoDto
|
|
||||||
sharedToUsers: NoteUserPermissionEntryDto[]
|
|
||||||
sharedToGroups: NoteGroupPermissionEntryDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteUserPermissionEntryDto {
|
|
||||||
user: UserInfoDto
|
|
||||||
canEdit: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteGroupPermissionEntryDto {
|
|
||||||
group: GroupInfoDto
|
|
||||||
canEdit: boolean
|
|
||||||
}
|
|
52
src/api/notes/types.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { Alias } from '../alias/types'
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
content: string
|
||||||
|
metadata: NoteMetadata
|
||||||
|
editedByAtPosition: NoteEdit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteMetadata {
|
||||||
|
id: string
|
||||||
|
aliases: Alias[]
|
||||||
|
primaryAddress: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
updatedAt: string
|
||||||
|
updateUsername: string | null
|
||||||
|
viewCount: number
|
||||||
|
createdAt: string
|
||||||
|
editedBy: string[]
|
||||||
|
permissions: NotePermissions
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteEdit {
|
||||||
|
username: string | null
|
||||||
|
startPos: number
|
||||||
|
endPos: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotePermissions {
|
||||||
|
owner: string | null
|
||||||
|
sharedToUsers: NoteUserPermissionEntry[]
|
||||||
|
sharedToGroups: NoteGroupPermissionEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteUserPermissionEntry {
|
||||||
|
username: string
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteGroupPermissionEntry {
|
||||||
|
groupName: string
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
96
src/api/permissions/index.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { NotePermissions } from '../notes/types'
|
||||||
|
import type { OwnerChangeDto, PermissionSetDto } from './types'
|
||||||
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the owner of a note.
|
||||||
|
* @param noteId The id of the note.
|
||||||
|
* @param owner The username of the new owner.
|
||||||
|
* @return The updated note permissions.
|
||||||
|
*/
|
||||||
|
export const setNoteOwner = async (noteId: string, owner: string): Promise<NotePermissions> => {
|
||||||
|
const response = await new PutApiRequestBuilder<NotePermissions, OwnerChangeDto>(
|
||||||
|
`notes/${noteId}/metadata/permissions/owner`
|
||||||
|
)
|
||||||
|
.withJsonBody({
|
||||||
|
owner
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a permission for one user of a note.
|
||||||
|
* @param noteId The id of the note.
|
||||||
|
* @param username The username of the user to set the permission for.
|
||||||
|
* @param canEdit true if the user should be able to update the note, false otherwise.
|
||||||
|
*/
|
||||||
|
export const setUserPermission = async (
|
||||||
|
noteId: string,
|
||||||
|
username: string,
|
||||||
|
canEdit: boolean
|
||||||
|
): Promise<NotePermissions> => {
|
||||||
|
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
||||||
|
`notes/${noteId}/metadata/permissions/users/${username}`
|
||||||
|
)
|
||||||
|
.withJsonBody({
|
||||||
|
canEdit
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a permission for one group of a note.
|
||||||
|
* @param noteId The id of the note.
|
||||||
|
* @param groupName The name of the group to set the permission for.
|
||||||
|
* @param canEdit true if the group should be able to update the note, false otherwise.
|
||||||
|
*/
|
||||||
|
export const setGroupPermission = async (
|
||||||
|
noteId: string,
|
||||||
|
groupName: string,
|
||||||
|
canEdit: boolean
|
||||||
|
): Promise<NotePermissions> => {
|
||||||
|
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
||||||
|
`notes/${noteId}/metadata/permissions/groups/${groupName}`
|
||||||
|
)
|
||||||
|
.withJsonBody({
|
||||||
|
canEdit
|
||||||
|
})
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the permissions of a note for a user.
|
||||||
|
* @param noteId The id of the note.
|
||||||
|
* @param username The name of the user to remove the permission of.
|
||||||
|
*/
|
||||||
|
export const removeUserPermission = async (noteId: string, username: string): Promise<NotePermissions> => {
|
||||||
|
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
||||||
|
`notes/${noteId}/metadata/permissions/users/${username}`
|
||||||
|
)
|
||||||
|
.withExpectedStatusCode(200)
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the permissions of a note for a group.
|
||||||
|
* @param noteId The id of the note.
|
||||||
|
* @param groupName The name of the group to remove the permission of.
|
||||||
|
*/
|
||||||
|
export const removeGroupPermission = async (noteId: string, groupName: string): Promise<NotePermissions> => {
|
||||||
|
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
||||||
|
`notes/${noteId}/metadata/permissions/groups/${groupName}`
|
||||||
|
)
|
||||||
|
.withExpectedStatusCode(200)
|
||||||
|
.sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
12
src/api/permissions/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
export interface OwnerChangeDto {
|
||||||
|
owner: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionSetDto {
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
|
@ -1,34 +1,39 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { RevisionDetails, RevisionMetadata } from './types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
import { Cache } from '../../components/common/cache/cache'
|
/**
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
* Retrieves a note revision while using a cache for often retrieved revisions.
|
||||||
import type { Revision, RevisionListEntry } from './types'
|
* @param noteId The id of the note for which to fetch the revision.
|
||||||
|
* @param revisionId The id of the revision to fetch.
|
||||||
const revisionCache = new Cache<string, Revision>(3600)
|
* @return The revision.
|
||||||
|
*/
|
||||||
export const getRevision = async (noteId: string, timestamp: number): Promise<Revision> => {
|
export const getRevision = async (noteId: string, revisionId: number): Promise<RevisionDetails> => {
|
||||||
const cacheKey = `${noteId}:${timestamp}`
|
const response = await new GetApiRequestBuilder<RevisionDetails>(
|
||||||
if (revisionCache.has(cacheKey)) {
|
`notes/${noteId}/revisions/${revisionId}`
|
||||||
return revisionCache.get(cacheKey)
|
).sendRequest()
|
||||||
}
|
return response.asParsedJsonObject()
|
||||||
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions/${timestamp}`, {
|
|
||||||
...defaultFetchConfig
|
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
const revisionData = (await response.json()) as Revision
|
|
||||||
revisionCache.put(cacheKey, revisionData)
|
|
||||||
return revisionData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAllRevisions = async (noteId: string): Promise<RevisionListEntry[]> => {
|
/**
|
||||||
// TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data!
|
* Retrieves a list of all revisions stored for a given note.
|
||||||
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions-list`, {
|
* @param noteId The id of the note for which to look up the stored revisions.
|
||||||
...defaultFetchConfig
|
* @return A list of revision ids.
|
||||||
})
|
*/
|
||||||
expectResponseCode(response)
|
export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[]> => {
|
||||||
return (await response.json()) as Promise<RevisionListEntry[]>
|
const response = await new GetApiRequestBuilder<RevisionMetadata[]>(`notes/${noteId}/revisions`).sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all revisions for a note.
|
||||||
|
* @param noteIdOrAlias The id or alias of the note to delete all revisions for.
|
||||||
|
*/
|
||||||
|
export const deleteRevisionsForNote = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
|
await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`).sendRequest()
|
||||||
}
|
}
|
||||||
|
|
17
src/api/revisions/types.d.ts
vendored
|
@ -1,17 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Revision {
|
|
||||||
content: string
|
|
||||||
timestamp: number
|
|
||||||
authors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RevisionListEntry {
|
|
||||||
timestamp: number
|
|
||||||
length: number
|
|
||||||
authors: string[]
|
|
||||||
}
|
|
21
src/api/revisions/types.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NoteEdit } from '../notes/types'
|
||||||
|
|
||||||
|
export interface RevisionDetails extends RevisionMetadata {
|
||||||
|
content: string
|
||||||
|
patch: string
|
||||||
|
edits: NoteEdit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionMetadata {
|
||||||
|
id: number
|
||||||
|
createdAt: string
|
||||||
|
length: number
|
||||||
|
authorUsernames: string[]
|
||||||
|
anonymousAuthorCount: number
|
||||||
|
}
|
|
@ -1,37 +1,42 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import type { AccessToken, AccessTokenWithSecret, CreateAccessTokenDto } from './types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
/**
|
||||||
import type { AccessToken, AccessTokenWithSecret } from './types'
|
* Retrieves the access tokens for the current user.
|
||||||
|
* @return List of access token metadata.
|
||||||
|
*/
|
||||||
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
||||||
const response = await fetch(`${getApiUrl()}tokens`, {
|
const response = await new GetApiRequestBuilder<AccessToken[]>('tokens').sendRequest()
|
||||||
...defaultFetchConfig
|
return response.asParsedJsonObject()
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
return (await response.json()) as AccessToken[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postNewAccessToken = async (label: string, expiryDate: string): Promise<AccessTokenWithSecret> => {
|
/**
|
||||||
const response = await fetch(`${getApiUrl()}tokens`, {
|
* Creates a new access token for the current user.
|
||||||
...defaultFetchConfig,
|
* @param label The user-defined label for the new access token.
|
||||||
method: 'POST',
|
* @param validUntil The user-defined expiry date of the new access token in milliseconds of unix time.
|
||||||
body: JSON.stringify({
|
* @return The new access token metadata along with its secret.
|
||||||
label: label,
|
*/
|
||||||
validUntil: expiryDate
|
export const postNewAccessToken = async (label: string, validUntil: number): Promise<AccessTokenWithSecret> => {
|
||||||
|
const response = await new PostApiRequestBuilder<AccessTokenWithSecret, CreateAccessTokenDto>('tokens')
|
||||||
|
.withJsonBody({
|
||||||
|
label,
|
||||||
|
validUntil
|
||||||
})
|
})
|
||||||
})
|
.sendRequest()
|
||||||
expectResponseCode(response)
|
return response.asParsedJsonObject()
|
||||||
return (await response.json()) as AccessTokenWithSecret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an access token from the current user account.
|
||||||
|
* @param keyId The key id of the access token to delete.
|
||||||
|
*/
|
||||||
export const deleteAccessToken = async (keyId: string): Promise<void> => {
|
export const deleteAccessToken = async (keyId: string): Promise<void> => {
|
||||||
const response = await fetch(`${getApiUrl()}tokens/${keyId}`, {
|
await new DeleteApiRequestBuilder('tokens/' + keyId).sendRequest()
|
||||||
...defaultFetchConfig,
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -9,9 +9,14 @@ export interface AccessToken {
|
||||||
validUntil: string
|
validUntil: string
|
||||||
keyId: string
|
keyId: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lastUsed: string
|
lastUsedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessTokenWithSecret extends AccessToken {
|
export interface AccessTokenWithSecret extends AccessToken {
|
||||||
secret: string
|
secret: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateAccessTokenDto {
|
||||||
|
label: string
|
||||||
|
validUntil: number
|
||||||
|
}
|
|
@ -1,24 +1,18 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Cache } from '../../components/common/cache/cache'
|
import type { UserInfo } from './types'
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
import type { UserResponse } from './types'
|
|
||||||
|
|
||||||
const cache = new Cache<string, UserResponse>(600)
|
/**
|
||||||
|
* Retrieves information about a specific user while using a cache to avoid many requests for the same username.
|
||||||
export const getUserById = async (userid: string): Promise<UserResponse> => {
|
* @param username The username of interest.
|
||||||
if (cache.has(userid)) {
|
* @return Metadata about the requested user.
|
||||||
return cache.get(userid)
|
*/
|
||||||
}
|
export const getUser = async (username: string): Promise<UserInfo> => {
|
||||||
const response = await fetch(`${getApiUrl()}/users/${userid}`, {
|
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
|
||||||
...defaultFetchConfig
|
return response.asParsedJsonObject()
|
||||||
})
|
|
||||||
expectResponseCode(response)
|
|
||||||
const userData = (await response.json()) as UserResponse
|
|
||||||
cache.put(userid, userData)
|
|
||||||
return userData
|
|
||||||
}
|
}
|
||||||
|
|
21
src/api/users/types.d.ts
vendored
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { LoginProvider } from '../../redux/user/types'
|
|
||||||
|
|
||||||
export interface UserResponse {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
photo: string
|
|
||||||
provider: LoginProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfoDto {
|
|
||||||
username: string
|
|
||||||
displayName: string
|
|
||||||
photo: string
|
|
||||||
email: string
|
|
||||||
}
|
|
11
src/api/users/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
photo: string
|
||||||
|
}
|
|
@ -1,29 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getGlobalState } from '../redux'
|
|
||||||
|
|
||||||
export const defaultFetchConfig: Partial<RequestInit> = {
|
|
||||||
mode: 'cors',
|
|
||||||
cache: 'no-cache',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
redirect: 'follow',
|
|
||||||
referrerPolicy: 'no-referrer',
|
|
||||||
method: 'GET'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getApiUrl = (): string => {
|
|
||||||
return getGlobalState().apiUrl.apiUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
export const expectResponseCode = (response: Response, code = 200): void => {
|
|
||||||
if (response.status !== code) {
|
|
||||||
throw new Error(`response code is not ${code}`)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,10 +6,8 @@
|
||||||
|
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { Suspense } from 'react'
|
import React, { Suspense } from 'react'
|
||||||
import { useBackendBaseUrl } from '../../hooks/common/use-backend-base-url'
|
|
||||||
import { createSetUpTaskList } from './initializers'
|
import { createSetUpTaskList } from './initializers'
|
||||||
import { LoadingScreen } from './loading-screen/loading-screen'
|
import { LoadingScreen } from './loading-screen/loading-screen'
|
||||||
import { useCustomizeAssetsUrl } from '../../hooks/common/use-customize-assets-url'
|
|
||||||
import { Logger } from '../../utils/logger'
|
import { Logger } from '../../utils/logger'
|
||||||
import { useAsync } from 'react-use'
|
import { useAsync } from 'react-use'
|
||||||
import { ApplicationLoaderError } from './application-loader-error'
|
import { ApplicationLoaderError } from './application-loader-error'
|
||||||
|
@ -17,11 +15,8 @@ import { ApplicationLoaderError } from './application-loader-error'
|
||||||
const log = new Logger('ApplicationLoader')
|
const log = new Logger('ApplicationLoader')
|
||||||
|
|
||||||
export const ApplicationLoader: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
export const ApplicationLoader: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
const backendBaseUrl = useBackendBaseUrl()
|
|
||||||
const customizeAssetsUrl = useCustomizeAssetsUrl()
|
|
||||||
|
|
||||||
const { error, loading } = useAsync(async () => {
|
const { error, loading } = useAsync(async () => {
|
||||||
const initTasks = createSetUpTaskList(customizeAssetsUrl, backendBaseUrl)
|
const initTasks = createSetUpTaskList()
|
||||||
for (const task of initTasks) {
|
for (const task of initTasks) {
|
||||||
try {
|
try {
|
||||||
await task.task
|
await task.task
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setMotd } from '../../../redux/motd/methods'
|
import { setMotd } from '../../../redux/motd/methods'
|
||||||
import { defaultFetchConfig } from '../../../api/utils'
|
|
||||||
import { Logger } from '../../../utils/logger'
|
import { Logger } from '../../../utils/logger'
|
||||||
|
import { customizeAssetsUrl } from '../../../utils/customize-assets-url'
|
||||||
|
import { defaultConfig } from '../../../api/common/default-config'
|
||||||
|
|
||||||
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
||||||
const log = new Logger('Motd')
|
const log = new Logger('Motd')
|
||||||
|
@ -16,17 +17,15 @@ const log = new Logger('Motd')
|
||||||
* If the motd hasn't changed since the last time then the global application state won't be changed.
|
* If the motd hasn't changed since the last time then the global application state won't be changed.
|
||||||
* To check if the motd has changed the "last modified" header from the request
|
* To check if the motd has changed the "last modified" header from the request
|
||||||
* will be compared to the saved value from the browser's local storage.
|
* will be compared to the saved value from the browser's local storage.
|
||||||
*
|
|
||||||
* @param customizeAssetsUrl the URL where the motd.md can be found.
|
|
||||||
* @return A promise that gets resolved if the motd was fetched successfully.
|
* @return A promise that gets resolved if the motd was fetched successfully.
|
||||||
*/
|
*/
|
||||||
export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => {
|
export const fetchMotd = async (): Promise<void> => {
|
||||||
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
|
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
|
||||||
const motdUrl = `${customizeAssetsUrl}motd.md`
|
const motdUrl = `${customizeAssetsUrl}motd.md`
|
||||||
|
|
||||||
if (cachedLastModified) {
|
if (cachedLastModified) {
|
||||||
const response = await fetch(motdUrl, {
|
const response = await fetch(motdUrl, {
|
||||||
...defaultFetchConfig,
|
...defaultConfig,
|
||||||
method: 'HEAD'
|
method: 'HEAD'
|
||||||
})
|
})
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
|
@ -39,7 +38,7 @@ export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(motdUrl, {
|
const response = await fetch(motdUrl, {
|
||||||
...defaultFetchConfig
|
...defaultConfig
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
import { setUpI18n } from './setupI18n'
|
import { setUpI18n } from './setupI18n'
|
||||||
import { refreshHistoryState } from '../../../redux/history/methods'
|
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||||
import { fetchMotd } from './fetch-motd'
|
import { fetchMotd } from './fetch-motd'
|
||||||
import { setApiUrl } from '../../../redux/api-url/methods'
|
|
||||||
import { fetchAndSetUser } from '../../login-page/auth/utils'
|
import { fetchAndSetUser } from '../../login-page/auth/utils'
|
||||||
import { fetchFrontendConfig } from './fetch-frontend-config'
|
import { fetchFrontendConfig } from './fetch-frontend-config'
|
||||||
import { loadDarkMode } from './load-dark-mode'
|
import { loadDarkMode } from './load-dark-mode'
|
||||||
|
@ -25,11 +24,7 @@ export interface InitTask {
|
||||||
task: Promise<void>
|
task: Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl: string): InitTask[] => {
|
export const createSetUpTaskList = (): InitTask[] => {
|
||||||
setApiUrl({
|
|
||||||
apiUrl: `${backendBaseUrl}api/private/`
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'Load dark mode',
|
name: 'Load dark mode',
|
||||||
|
@ -49,7 +44,7 @@ export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl:
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Motd',
|
name: 'Motd',
|
||||||
task: fetchMotd(customizeAssetsUrl)
|
task: fetchMotd()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Load history state',
|
name: 'Load history state',
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { Alert } from 'react-bootstrap'
|
||||||
|
|
||||||
export interface AsyncLoadingBoundaryProps {
|
export interface AsyncLoadingBoundaryProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error?: boolean
|
error?: Error | boolean
|
||||||
componentName: string
|
componentName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ export const AsyncLoadingBoundary: React.FC<PropsWithChildren<AsyncLoadingBounda
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
if (error === true) {
|
if (error !== undefined && error !== false) {
|
||||||
return (
|
return (
|
||||||
<Alert variant={'danger'}>
|
<Alert variant={'danger'}>
|
||||||
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} />
|
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} />
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Pagination } from 'react-bootstrap'
|
import { Pagination } from 'react-bootstrap'
|
||||||
import { ShowIf } from '../show-if/show-if'
|
import { ShowIf } from '../show-if/show-if'
|
||||||
import { PagerItem } from './pager-item'
|
import { PagerItem } from './pager-item'
|
||||||
|
@ -33,25 +33,34 @@ export const PagerPagination: React.FC<PaginationProps> = ({
|
||||||
onPageChange(pageIndex)
|
onPageChange(pageIndex)
|
||||||
}, [onPageChange, pageIndex])
|
}, [onPageChange, pageIndex])
|
||||||
|
|
||||||
const correctedLowerPageIndex = Math.min(
|
const correctedLowerPageIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
Math.min(
|
||||||
Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0),
|
Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0),
|
||||||
lastPageIndex
|
lastPageIndex
|
||||||
|
),
|
||||||
|
[wantedLowerPageIndex, lastPageIndex, wantedUpperPageIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
const correctedUpperPageIndex = Math.max(
|
const correctedUpperPageIndex = useMemo(
|
||||||
Math.min(Math.max(wantedUpperPageIndex, wantedUpperPageIndex - wantedLowerPageIndex), lastPageIndex),
|
() =>
|
||||||
0
|
Math.max(Math.min(Math.max(wantedUpperPageIndex, wantedUpperPageIndex - wantedLowerPageIndex), lastPageIndex), 0),
|
||||||
|
[wantedUpperPageIndex, lastPageIndex, wantedLowerPageIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
const paginationItemsBefore = Array.from(new Array(correctedPageIndex - correctedLowerPageIndex)).map((k, index) => {
|
const paginationItemsBefore = useMemo(() => {
|
||||||
|
return new Array(correctedPageIndex - correctedLowerPageIndex).map((k, index) => {
|
||||||
const itemIndex = correctedLowerPageIndex + index
|
const itemIndex = correctedLowerPageIndex + index
|
||||||
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
|
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
|
||||||
})
|
})
|
||||||
|
}, [correctedPageIndex, correctedLowerPageIndex, setPageIndex])
|
||||||
|
|
||||||
const paginationItemsAfter = Array.from(new Array(correctedUpperPageIndex - correctedPageIndex)).map((k, index) => {
|
const paginationItemsAfter = useMemo(() => {
|
||||||
|
return new Array(correctedUpperPageIndex - correctedPageIndex).map((k, index) => {
|
||||||
const itemIndex = correctedPageIndex + index + 1
|
const itemIndex = correctedPageIndex + index + 1
|
||||||
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
|
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
|
||||||
})
|
})
|
||||||
|
}, [correctedUpperPageIndex, correctedPageIndex, setPageIndex])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination dir='ltr'>
|
<Pagination dir='ltr'>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React, { Fragment, useEffect } from 'react'
|
import React, { Fragment, useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
export interface PagerPageProps {
|
export interface PagerPageProps {
|
||||||
pageIndex: number
|
pageIndex: number
|
||||||
|
@ -26,12 +26,12 @@ export const Pager: React.FC<PropsWithChildren<PagerPageProps>> = ({
|
||||||
onLastPageIndexChange(maxPageIndex)
|
onLastPageIndexChange(maxPageIndex)
|
||||||
}, [children, maxPageIndex, numberOfElementsPerPage, onLastPageIndexChange])
|
}, [children, maxPageIndex, numberOfElementsPerPage, onLastPageIndexChange])
|
||||||
|
|
||||||
return (
|
const filteredChildren = useMemo(() => {
|
||||||
<Fragment>
|
return React.Children.toArray(children).filter((value, index) => {
|
||||||
{React.Children.toArray(children).filter((value, index) => {
|
|
||||||
const pageOfElement = Math.floor(index / numberOfElementsPerPage)
|
const pageOfElement = Math.floor(index / numberOfElementsPerPage)
|
||||||
return pageOfElement === correctedPageIndex
|
return pageOfElement === correctedPageIndex
|
||||||
})}
|
})
|
||||||
</Fragment>
|
}, [children, numberOfElementsPerPage, correctedPageIndex])
|
||||||
)
|
|
||||||
|
return <Fragment>{filteredChildren}</Fragment>
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import React from 'react'
|
||||||
|
import { useAsync } from 'react-use'
|
||||||
|
import { getUser } from '../../../api/users'
|
||||||
|
import { customizeAssetsUrl } from '../../../utils/customize-assets-url'
|
||||||
|
import type { UserAvatarProps } from './user-avatar'
|
||||||
|
import { UserAvatar } from './user-avatar'
|
||||||
|
import type { UserInfo } from '../../../api/users/types'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AsyncLoadingBoundary } from '../async-loading-boundary'
|
||||||
|
|
||||||
|
export interface UserAvatarForUsernameProps extends Omit<UserAvatarProps, 'user'> {
|
||||||
|
username: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the user avatar for a given username.
|
||||||
|
* When no username is given, the guest user will be used as fallback.
|
||||||
|
*
|
||||||
|
* @see {UserAvatar}
|
||||||
|
*
|
||||||
|
* @param username The username for which to show the avatar or null to show the guest user avatar.
|
||||||
|
*/
|
||||||
|
export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ username, ...props }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { error, value, loading } = useAsync(async (): Promise<UserInfo> => {
|
||||||
|
if (username) {
|
||||||
|
return await getUser(username)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
displayName: t('common.guestUser'),
|
||||||
|
photo: `${customizeAssetsUrl}img/avatar.png`,
|
||||||
|
username: ''
|
||||||
|
}
|
||||||
|
}, [username, t])
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncLoadingBoundary loading={loading} error={error} componentName={'UserAvatarForUsername'}>
|
||||||
|
<UserAvatar user={value} {...props} />
|
||||||
|
</AsyncLoadingBoundary>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*!
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -9,3 +9,7 @@
|
||||||
flex: 1 1;
|
flex: 1 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-image {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -7,18 +7,25 @@
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ShowIf } from '../show-if/show-if'
|
import { ShowIf } from '../show-if/show-if'
|
||||||
import Image from 'next/image'
|
|
||||||
import styles from './user-avatar.module.scss'
|
import styles from './user-avatar.module.scss'
|
||||||
|
import type { UserInfo } from '../../../api/users/types'
|
||||||
|
|
||||||
export interface UserAvatarProps {
|
export interface UserAvatarProps {
|
||||||
size?: 'sm' | 'lg'
|
size?: 'sm' | 'lg'
|
||||||
name: string
|
|
||||||
photo: string
|
|
||||||
additionalClasses?: string
|
additionalClasses?: string
|
||||||
showName?: boolean
|
showName?: boolean
|
||||||
|
user: UserInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserAvatar: React.FC<UserAvatarProps> = ({ name, photo, size, additionalClasses = '', showName = true }) => {
|
/**
|
||||||
|
* Renders the avatar image of a user, optionally altogether with their name.
|
||||||
|
*
|
||||||
|
* @param user The user object with the display name and photo.
|
||||||
|
* @param size The size in which the user image should be shown.
|
||||||
|
* @param additionalClasses Additional CSS classes that will be added to the container.
|
||||||
|
* @param showName true when the name should be displayed alongside the image, false otherwise. Defaults to true.
|
||||||
|
*/
|
||||||
|
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, size, additionalClasses = '', showName = true }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const imageSize = useMemo(() => {
|
const imageSize = useMemo(() => {
|
||||||
|
@ -34,20 +41,18 @@ const UserAvatar: React.FC<UserAvatarProps> = ({ name, photo, size, additionalCl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={'d-inline-flex align-items-center ' + additionalClasses}>
|
<span className={'d-inline-flex align-items-center ' + additionalClasses}>
|
||||||
<Image
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
src={photo}
|
<img
|
||||||
className={`rounded`}
|
src={user.photo}
|
||||||
alt={t('common.avatarOf', { name })}
|
className={`rounded ${styles['user-image']}`}
|
||||||
title={name}
|
alt={t('common.avatarOf', { name: user.displayName })}
|
||||||
|
title={user.displayName}
|
||||||
height={imageSize}
|
height={imageSize}
|
||||||
width={imageSize}
|
width={imageSize}
|
||||||
layout={'fixed'}
|
|
||||||
/>
|
/>
|
||||||
<ShowIf condition={showName}>
|
<ShowIf condition={showName}>
|
||||||
<span className={`ml-2 mr-1 ${styles['user-line-name']}`}>{name}</span>
|
<span className={`ml-2 mr-1 ${styles['user-line-name']}`}>{user.displayName}</span>
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { UserAvatar }
|
|
||||||
|
|
|
@ -3,66 +3,38 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DateTime } from 'luxon'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { InternalLink } from '../common/links/internal-link'
|
import { InternalLink } from '../common/links/internal-link'
|
||||||
import { ShowIf } from '../common/show-if/show-if'
|
import { ShowIf } from '../common/show-if/show-if'
|
||||||
import {
|
|
||||||
DocumentInfoLineWithTimeMode,
|
|
||||||
DocumentInfoTimeLine
|
|
||||||
} from '../editor-page/document-bar/document-info/document-info-time-line'
|
|
||||||
import styles from './document-infobar.module.scss'
|
import styles from './document-infobar.module.scss'
|
||||||
import { useCustomizeAssetsUrl } from '../../hooks/common/use-customize-assets-url'
|
import { useApplicationState } from '../../hooks/common/use-application-state'
|
||||||
|
import { NoteInfoLineCreated } from '../editor-page/document-bar/note-info/note-info-line-created'
|
||||||
|
import { NoteInfoLineUpdated } from '../editor-page/document-bar/note-info/note-info-line-updated'
|
||||||
|
|
||||||
export interface DocumentInfobarProps {
|
/**
|
||||||
changedAuthor: string
|
* Renders an infobar with metadata about the current note.
|
||||||
changedTime: DateTime
|
*/
|
||||||
createdAuthor: string
|
export const DocumentInfobar: React.FC = () => {
|
||||||
createdTime: DateTime
|
|
||||||
editable: boolean
|
|
||||||
noteId: string
|
|
||||||
viewCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DocumentInfobar: React.FC<DocumentInfobarProps> = ({
|
|
||||||
changedAuthor,
|
|
||||||
changedTime,
|
|
||||||
createdAuthor,
|
|
||||||
createdTime,
|
|
||||||
editable,
|
|
||||||
noteId,
|
|
||||||
viewCount
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const assetsBaseUrl = useCustomizeAssetsUrl()
|
const noteDetails = useApplicationState((state) => state.noteDetails)
|
||||||
|
|
||||||
|
// TODO Check permissions ("writability") of note and show edit link depending on that.
|
||||||
return (
|
return (
|
||||||
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
|
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
|
||||||
<div className={'col-md'}> </div>
|
<div className={'col-md'}> </div>
|
||||||
<div className={'d-flex flex-fill'}>
|
<div className={'d-flex flex-fill'}>
|
||||||
<div className={'d-flex flex-column'}>
|
<div className={'d-flex flex-column'}>
|
||||||
<DocumentInfoTimeLine
|
<NoteInfoLineCreated />
|
||||||
mode={DocumentInfoLineWithTimeMode.CREATED}
|
<NoteInfoLineUpdated />
|
||||||
time={createdTime}
|
|
||||||
userName={createdAuthor}
|
|
||||||
profileImageSrc={`${assetsBaseUrl}/img/avatar.png`}
|
|
||||||
/>
|
|
||||||
<DocumentInfoTimeLine
|
|
||||||
mode={DocumentInfoLineWithTimeMode.EDITED}
|
|
||||||
time={changedTime}
|
|
||||||
userName={changedAuthor}
|
|
||||||
profileImageSrc={`${assetsBaseUrl}/img/avatar.png`}
|
|
||||||
/>
|
|
||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
<span className={'ml-auto'}>
|
<span className={'ml-auto'}>
|
||||||
{viewCount} <Trans i18nKey={'views.readOnly.viewCount'} />
|
{noteDetails.viewCount} <Trans i18nKey={'views.readOnly.viewCount'} />
|
||||||
<ShowIf condition={editable}>
|
<ShowIf condition={true}>
|
||||||
<InternalLink
|
<InternalLink
|
||||||
text={''}
|
text={''}
|
||||||
href={`/n/${noteId}`}
|
href={`/n/${noteDetails.primaryAddress}`}
|
||||||
icon={'pencil'}
|
icon={'pencil'}
|
||||||
className={'text-primary text-decoration-none mx-1'}
|
className={'text-primary text-decoration-none mx-1'}
|
||||||
title={t('views.readOnly.editNote')}
|
title={t('views.readOnly.editNote')}
|
||||||
|
|
|
@ -7,31 +7,25 @@
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
|
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
|
||||||
import { useApplicationState } from '../../hooks/common/use-application-state'
|
|
||||||
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
|
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
|
||||||
import { DocumentInfobar } from './document-infobar'
|
import { DocumentInfobar } from './document-infobar'
|
||||||
import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
|
import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
|
||||||
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
|
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
|
||||||
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
|
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the read-only version of a note with a header bar that contains information about the note.
|
||||||
|
*/
|
||||||
export const DocumentReadOnlyPageContent: React.FC = () => {
|
export const DocumentReadOnlyPageContent: React.FC = () => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
|
||||||
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
|
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
|
||||||
const noteDetails = useApplicationState((state) => state.noteDetails)
|
|
||||||
useSendFrontmatterInfoFromReduxToRenderer()
|
useSendFrontmatterInfoFromReduxToRenderer()
|
||||||
|
|
||||||
|
// TODO Change todo values with real ones as soon as the backend is ready.
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<DocumentInfobar
|
<DocumentInfobar />
|
||||||
changedAuthor={noteDetails.lastChange.username ?? ''}
|
|
||||||
changedTime={noteDetails.lastChange.timestamp}
|
|
||||||
createdAuthor={'Test'}
|
|
||||||
createdTime={noteDetails.createTime}
|
|
||||||
editable={true}
|
|
||||||
noteId={noteDetails.id}
|
|
||||||
viewCount={noteDetails.viewCount}
|
|
||||||
/>
|
|
||||||
<RenderIframe
|
<RenderIframe
|
||||||
frameClasses={'flex-fill h-100 w-100'}
|
frameClasses={'flex-fill h-100 w-100'}
|
||||||
markdownContentLines={markdownContentLines}
|
markdownContentLines={markdownContentLines}
|
||||||
|
|
|
@ -11,12 +11,15 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button that links to the read-only version of a note.
|
||||||
|
*/
|
||||||
export const ReadOnlyModeButton: React.FC = () => {
|
export const ReadOnlyModeButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const id = useApplicationState((state) => state.noteDetails.id)
|
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/s/${id}`}>
|
<Link href={`/s/${noteIdentifier}`}>
|
||||||
<a target='_blank'>
|
<a target='_blank'>
|
||||||
<Button
|
<Button
|
||||||
title={t('editor.documentBar.readOnlyMode')}
|
title={t('editor.documentBar.readOnlyMode')}
|
||||||
|
|
|
@ -11,12 +11,15 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button that links to the slide-show presentation of the current note.
|
||||||
|
*/
|
||||||
export const SlideModeButton: React.FC = () => {
|
export const SlideModeButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const id = useApplicationState((state) => state.noteDetails.id)
|
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/p/${id}`}>
|
<Link href={`/p/${noteIdentifier}`}>
|
||||||
<a target='_blank'>
|
<a target='_blank'>
|
||||||
<Button
|
<Button
|
||||||
title={t('editor.documentBar.slideMode')}
|
title={t('editor.documentBar.slideMode')}
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import React from 'react'
|
|
||||||
import { ListGroup, Modal } from 'react-bootstrap'
|
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
|
||||||
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
|
|
||||||
import { CommonModal } from '../../../common/modals/common-modal'
|
|
||||||
import { DocumentInfoLine } from './document-info-line'
|
|
||||||
import { DocumentInfoLineWithTimeMode, DocumentInfoTimeLine } from './document-info-time-line'
|
|
||||||
import { UnitalicBoldText } from './unitalic-bold-text'
|
|
||||||
import { useCustomizeAssetsUrl } from '../../../../hooks/common/use-customize-assets-url'
|
|
||||||
import { DocumentInfoLineWordCount } from './document-info-line-word-count'
|
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
|
||||||
|
|
||||||
export const DocumentInfoModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
|
||||||
const assetsBaseUrl = useCustomizeAssetsUrl()
|
|
||||||
useTranslation()
|
|
||||||
|
|
||||||
// TODO Replace hardcoded mock data with real/mock API requests
|
|
||||||
return (
|
|
||||||
<CommonModal
|
|
||||||
show={show}
|
|
||||||
onHide={onHide}
|
|
||||||
showCloseButton={true}
|
|
||||||
title={'editor.modal.documentInfo.title'}
|
|
||||||
{...cypressId('document-info-modal')}>
|
|
||||||
<Modal.Body>
|
|
||||||
<ListGroup>
|
|
||||||
<ListGroup.Item>
|
|
||||||
<DocumentInfoTimeLine
|
|
||||||
size={'2x'}
|
|
||||||
mode={DocumentInfoLineWithTimeMode.CREATED}
|
|
||||||
time={DateTime.local().minus({ days: 11 })}
|
|
||||||
userName={'Tilman'}
|
|
||||||
profileImageSrc={`${assetsBaseUrl}img/avatar.png`}
|
|
||||||
/>
|
|
||||||
</ListGroup.Item>
|
|
||||||
<ListGroup.Item>
|
|
||||||
<DocumentInfoTimeLine
|
|
||||||
size={'2x'}
|
|
||||||
mode={DocumentInfoLineWithTimeMode.EDITED}
|
|
||||||
time={DateTime.local().minus({ minutes: 3 })}
|
|
||||||
userName={'Philip'}
|
|
||||||
profileImageSrc={`${assetsBaseUrl}img/avatar.png`}
|
|
||||||
/>
|
|
||||||
</ListGroup.Item>
|
|
||||||
<ListGroup.Item>
|
|
||||||
<DocumentInfoLine icon={'users'} size={'2x'}>
|
|
||||||
<Trans i18nKey='editor.modal.documentInfo.usersContributed'>
|
|
||||||
<UnitalicBoldText text={'42'} />
|
|
||||||
</Trans>
|
|
||||||
</DocumentInfoLine>
|
|
||||||
</ListGroup.Item>
|
|
||||||
<ListGroup.Item>
|
|
||||||
<DocumentInfoLine icon={'history'} size={'2x'}>
|
|
||||||
<Trans i18nKey='editor.modal.documentInfo.revisions'>
|
|
||||||
<UnitalicBoldText text={'192'} />
|
|
||||||
</Trans>
|
|
||||||
</DocumentInfoLine>
|
|
||||||
</ListGroup.Item>
|
|
||||||
<ListGroup.Item>
|
|
||||||
<DocumentInfoLineWordCount />
|
|
||||||
</ListGroup.Item>
|
|
||||||
</ListGroup>
|
|
||||||
</Modal.Body>
|
|
||||||
</CommonModal>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,56 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DateTime } from 'luxon'
|
|
||||||
import React from 'react'
|
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
|
||||||
import type { IconName } from '../../../common/fork-awesome/types'
|
|
||||||
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
|
|
||||||
import { DocumentInfoLine } from './document-info-line'
|
|
||||||
import { TimeFromNow } from './time-from-now'
|
|
||||||
|
|
||||||
export interface DocumentInfoLineWithTimeProps {
|
|
||||||
size?: '2x' | '3x' | '4x' | '5x' | undefined
|
|
||||||
time: DateTime
|
|
||||||
mode: DocumentInfoLineWithTimeMode
|
|
||||||
userName: string
|
|
||||||
profileImageSrc: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DocumentInfoLineWithTimeMode {
|
|
||||||
CREATED,
|
|
||||||
EDITED
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DocumentInfoTimeLine: React.FC<DocumentInfoLineWithTimeProps> = ({
|
|
||||||
time,
|
|
||||||
mode,
|
|
||||||
userName,
|
|
||||||
profileImageSrc,
|
|
||||||
size
|
|
||||||
}) => {
|
|
||||||
useTranslation()
|
|
||||||
|
|
||||||
const i18nKey =
|
|
||||||
mode === DocumentInfoLineWithTimeMode.CREATED
|
|
||||||
? 'editor.modal.documentInfo.created'
|
|
||||||
: 'editor.modal.documentInfo.edited'
|
|
||||||
const icon: IconName = mode === DocumentInfoLineWithTimeMode.CREATED ? 'plus' : 'pencil'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentInfoLine icon={icon} size={size}>
|
|
||||||
<Trans i18nKey={i18nKey}>
|
|
||||||
<UserAvatar
|
|
||||||
photo={profileImageSrc}
|
|
||||||
additionalClasses={'font-style-normal bold font-weight-bold'}
|
|
||||||
name={userName}
|
|
||||||
size={size ? 'lg' : undefined}
|
|
||||||
/>
|
|
||||||
<TimeFromNow time={time} />
|
|
||||||
</Trans>
|
|
||||||
</DocumentInfoLine>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
|
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
|
||||||
|
|
||||||
export interface UnitalicBoldTextProps extends PropsWithDataCypressId {
|
|
||||||
text: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UnitalicBoldText: React.FC<UnitalicBoldTextProps> = ({ text, ...props }) => {
|
|
||||||
return (
|
|
||||||
<b className={'font-style-normal mr-1'} {...cypressId(props)}>
|
|
||||||
{text}
|
|
||||||
</b>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Trans } from 'react-i18next'
|
||||||
|
import { UnitalicBoldContent } from './unitalic-bold-content'
|
||||||
|
import { NoteInfoLine } from './note-info-line'
|
||||||
|
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an info line about the number of contributors for the note.
|
||||||
|
*/
|
||||||
|
export const NoteInfoLineContributors: React.FC = () => {
|
||||||
|
const contributors = useApplicationState((state) => state.noteDetails.editedBy.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoteInfoLine icon={'users'} size={'2x'}>
|
||||||
|
<Trans i18nKey={'editor.modal.documentInfo.usersContributed'}>
|
||||||
|
<UnitalicBoldContent text={contributors} />
|
||||||
|
</Trans>
|
||||||
|
</NoteInfoLine>
|
||||||
|
)
|
||||||
|
}
|