Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to
- (frontend) manage members (update role / list / remove) (#81)
- ✨(frontend) offline mode (#88)
- (frontend) translate cgu (#83)
- ✨(service-worker) offline doc management (#94)

## Changed

Expand Down
13 changes: 13 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,19 @@ class DocumentViewSet(
resource_field_name = "document"
queryset = models.Document.objects.all()

def perform_create(self, serializer):
"""
Override perform_create to use the provided ID in the payload if it exists
"""
document_id = self.request.data.get("id")
document = serializer.save(id=document_id) if document_id else serializer.save()

self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: document},
)

@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
Expand Down
25 changes: 25 additions & 0 deletions src/backend/core/tests/documents/test_api_documents_create.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
import uuid

import pytest
from rest_framework.test import APIClient

Expand Down Expand Up @@ -45,3 +47,26 @@ def test_api_documents_create_authenticated():
document = Document.objects.get()
assert document.title == "my document"
assert document.accesses.filter(role="owner", user=user).exists()


def test_api_documents_create_with_id_from_payload():
"""
We should be able to create a document with an ID from the payload.
"""
user = factories.UserFactory()

client = APIClient()
client.force_login(user)

doc_id = uuid.uuid4()
response = client.post(
"/api/v1.0/documents/",
{"title": "my document", "id": str(doc_id)},
format="json",
)

assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.id == doc_id
assert document.accesses.filter(role="owner", user=user).exists()
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ test.describe('Doc Editor', () => {
})
.click();

await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();

const card = page.getByLabel('Create new document card').first();
await expect(
card.getByRole('heading', {
Expand Down
1 change: 1 addition & 0 deletions src/frontend/apps/impress/.env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_SIGNALING_URL=ws://localhost:4444
NEXT_PUBLIC_SW_DEACTIVATED=true
14 changes: 11 additions & 3 deletions src/frontend/apps/impress/next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const crypto = require('crypto');

const { InjectManifest } = require('workbox-webpack-plugin');

const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);

/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
Expand All @@ -11,7 +15,11 @@ const nextConfig = {
// Enables the styled-components SWC transform
styledComponents: true,
},
webpack(config, { isServer, dev }) {
generateBuildId: () => buildId,
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
webpack(config, { isServer }) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
Expand All @@ -33,10 +41,10 @@ const nextConfig = {
},
);

if (!isServer && !dev) {
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
config.plugins.push(
new InjectManifest({
swSrc: './src/core/service-worker.ts',
swSrc: './src/features/service-worker/service-worker.ts',
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "prettier --check . && yarn stylelint && next build",
"build:ci": "cp .env.development .env.local && cross-env NEXT_PUBLIC_CI=true yarn build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes",
"start": "npx -y serve@latest out",
"lint": "tsc --noEmit && next lint",
Expand All @@ -21,8 +21,8 @@
"@gouvfr-lasuite/integration": "1.0.1",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.48.0",
"cross-env": "*",
"i18next": "23.11.5",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.2.4",
Expand All @@ -49,6 +49,7 @@
"@types/node": "*",
"@types/react": "18.3.3",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/apps/impress/src/custom-next.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_SIGNALING_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';

import { useUpdateDoc } from '@/features/docs/doc-management/';

import { toBase64 } from '../utils';

const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc();
const { toast } = useToastProvider();
const { t } = useTranslation();

const { mutate: updateDoc } = useUpdateDoc({
onSuccess: (data) => {
toast(
t('Your document "{{docTitle}}" has been saved.', {
docTitle: data.title,
}),
VariantType.SUCCESS,
);
},
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
);
Expand Down Expand Up @@ -40,23 +54,23 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
};
}, [doc]);

const saveDoc = useCallback(() => {
/**
* Check if the doc has been updated and can be saved.
*/
const shouldSave = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc && canSave;
}, [canSave, doc, initialDoc]);

/**
* Save only if the doc has changed.
*/
if (initialDoc === newDoc || !canSave) {
return;
}

const saveDoc = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
setInitialDoc(newDoc);

updateDoc({
id: docId,
content: newDoc,
});
}, [initialDoc, docId, doc, updateDoc, canSave]);
}, [doc, docId, updateDoc]);

const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
Expand All @@ -66,8 +80,26 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
clearTimeout(timeout.current);
}

const onSave = () => {
const onSave = (e?: Event) => {
if (!shouldSave()) {
return;
}

saveDoc();

/**
* Firefox does not trigger the request everytime the user leaves the page.
* Plus the request is not intercepted by the service worker.
* So we prevent the default behavior to have the popup asking the user
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*/
const isFirefox =
navigator.userAgent.toLowerCase().indexOf('firefox') > -1;

if (typeof e !== 'undefined' && e.preventDefault && isFirefox) {
e.preventDefault();
}
};

// Save every minute
Expand All @@ -82,7 +114,7 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};
}, [router.events, saveDoc]);
}, [router.events, saveDoc, shouldSave]);
};

export default useSaveDoc;
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type DocsAPIParams = DocsParams & {
page: number;
};

type DocsResponse = APIList<Doc>;
export type DocsResponse = APIList<Doc>;

export const getDocs = async ({
ordering,
Expand Down
Loading