Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement sharing of document through netlify blobs #1135

Merged
merged 7 commits into from
Sep 20, 2024
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ build
dist
.turbo
.env
apps/design-system/src/styles/tailwind.output.css
apps/design-system/src/styles/tailwind.output.css
# Local Netlify folder for testing purposes
.netlify
6 changes: 6 additions & 0 deletions apps/studio-next/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[devs]
functions = "apps/studio-next/src/netlify/functions"
targetPort = 3001

[build]
functions = "apps/studio-next/src/netlify/functions"
22 changes: 13 additions & 9 deletions apps/studio-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"cy:e2e:chrome": "cypress run --e2e --browser chrome",
"cy:e2e:chromium": "cypress run --e2e --browser chromium",
"cy:e2e:edge": "cypress run --e2e --browser edge",
Expand All @@ -22,29 +23,32 @@
"@asyncapi/protobuf-schema-parser": "^3.2.8",
"@asyncapi/react-component": "^1.2.2",
"@asyncapi/specs": "^6.5.4",
"@codemirror/view": "^6.26.3",
"@ebay/nice-modal-react": "^1.2.10",
"@headlessui/react": "^1.7.4",
"@hookstate/core": "^4.0.0-rc21",
"@monaco-editor/react": "^4.4.6",
"@tippyjs/react": "^4.2.6",
"js-base64": "^3.7.3",
"js-file-download": "^0.4.12",
"js-yaml": "^4.1.0",
"monaco-editor": "0.34.1",
"monaco-yaml": "4.0.2",
"react-hot-toast": "2.4.0",
"react-icons": "^4.6.0",
"reactflow": "^11.2.0",
"@netlify/blobs": "^8.0.1",
"@netlify/functions": "^2.8.1",
"@stoplight/yaml": "^4.3.0",
"@tippyjs/react": "^4.2.6",
"@types/node": "20.4.6",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.14",
"eslint-config-next": "13.4.12",
"js-base64": "^3.7.3",
"js-file-download": "^0.4.12",
"js-yaml": "^4.1.0",
"monaco-editor": "0.34.1",
"monaco-yaml": "4.0.2",
"next": "14.2.3",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "2.4.0",
"react-icons": "^4.6.0",
"reactflow": "^11.2.0",
"tailwindcss": "3.3.3",
"tippy.js": "^6.3.7",
"typescript": "5.1.6",
Expand Down
45 changes: 45 additions & 0 deletions apps/studio-next/src/components/Editor/EditorDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ImportBase64Modal,
GeneratorModal,
ConvertModal,
ImportUUIDModal,
} from '../Modals';
import { Dropdown } from '../common';

Expand All @@ -34,6 +35,17 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
</button>
);

const importShareIdButton = (
<button
type="button"
className="px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150"
title="Import from UUID"
onClick={() => show(ImportUUIDModal)}
>
Import from UUID
</button>
);

const importFileButton = (
<label
className="block px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer"
Expand Down Expand Up @@ -208,6 +220,31 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
</button>
);

const shareButtonBase64 = (
<button
type="button"
className="px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150 disabled:cursor-not-allowed"
title='Share as Base64'
onClick={() => {
toast.promise(
(async function () {
const base64 = await editorSvc.exportAsBase64();
const url = `${window.location.origin}/?base64=${encodeURIComponent(
base64
)}`;
await navigator.clipboard.writeText(url);
}()),
{
loading: 'Copying URL to clipboard...',
success: 'URL copied to clipboard!',
error: 'Failed to copy URL to clipboard.',
}
);
}}>
Share as Base64
</button>
);

return (
<Dropdown
opener={<FaEllipsisH />}
Expand All @@ -224,12 +261,20 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
<li className="hover:bg-gray-900">
{importBase64Button}
</li>
<li className="hover:bg-gray-900">
{importShareIdButton}
</li>
</div>
<div className="border-b border-gray-700">
<li className="hover:bg-gray-900">
{generateButton}
</li>
</div>
<div className="border-b border-gray-700">
<li className="hover:bg-gray-900">
{shareButtonBase64}
</li>
</div>
<div className="border-b border-gray-700">
<li className="hover:bg-gray-900">
{saveFileButton}
Expand Down
2 changes: 2 additions & 0 deletions apps/studio-next/src/components/Editor/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const EditorSidebar: React.FunctionComponent<
documentFromText = 'From localStorage';
} else if (from === 'base64') {
documentFromText = 'From Base64';
} else if (from === 'share') {
documentFromText = 'From Shared';
} else {
documentFromText = `From URL ${source}`;
}
Expand Down
7 changes: 2 additions & 5 deletions apps/studio-next/src/components/Editor/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@ export const ShareButton: React.FunctionComponent<ShareButtonProps> = () => {
const handleShare = () => {
toast.promise(
(async function () {
const base64 = await editorSvc.exportAsBase64();
const url = `${window.location.origin}/?base64=${encodeURIComponent(
base64
)}`;
const url = await editorSvc.exportAsURL();
await navigator.clipboard.writeText(url);
}()),
{
loading: 'Copying URL to clipboard...',
success: 'URL copied to clipboard!',
error: 'Failed to copy URL to clipboard.',
error: 'Failed to share the AsyncAPI document.',
}
);
};
Expand Down
57 changes: 57 additions & 0 deletions apps/studio-next/src/components/Modals/ImportUUIDModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { create } from '@ebay/nice-modal-react';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that we've used this library without an ADR, It's fine.
Could we just add an ADR for it, to understand the reasoning behind it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let me check in which commit it was introduced initially.

Copy link
Collaborator Author

@Shurtu-gal Shurtu-gal Sep 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Amzani it was introduced in this PR #518.

Should an ADR still be made?


import { ConfirmModal } from './index';

import { useServices } from '../../services';

export const ImportUUIDModal = create(() => {
const [shareID, setShareID] = useState('');
const { editorSvc } = useServices();

const onSubmit = () => {
toast.promise(editorSvc.importFromShareID(shareID), {
loading: 'Importing...',
success: (
<div>
<span className="block text-bold">
Document succesfully imported!
</span>
</div>
),
error: (
<div>
<span className="block text-bold text-red-400">
Failed to import document.
</span>
</div>
),
});
};

return (
<ConfirmModal
title="Import AsyncAPI document from Shared UUID"
confirmText="Import"
confirmDisabled={!shareID}
onSubmit={onSubmit}
>
<div className="flex content-center justify-center">
<label
htmlFor="url"
className="flex justify-right items-center content-center text-sm font-medium text-gray-700 hidden"
>
Shared UUID
</label>
<input
type="url"
name="url"
placeholder="Paste UUID here"
className="shadow-sm focus:ring-pink-500 focus:border-pink-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 text-gray-700 border-pink-300 border-2"
onChange={e => setShareID(e.target.value)}
/>
</div>
</ConfirmModal>
);
});
1 change: 1 addition & 0 deletions apps/studio-next/src/components/Modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './ConvertModal';
export * from './ConvertToLatestModal';
export * from './ImportBase64Modal';
export * from './ImportURLModal';
export * from './ImportUUIDModal';
export * from './NewFileModal';
export * from './RedirectedModal';
export * from './ConfirmNewFileModal';
27 changes: 27 additions & 0 deletions apps/studio-next/src/netlify/functions/share-retreive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getStore } from '@netlify/blobs';
import type { Config, Context } from '@netlify/functions';

export default async (req: Request, context: Context) => {
const share = getStore('share');
const { shareId } = context.params;

if (!shareId) {
return new Response('Not found', { status: 404 });
}

const shareData = await share.get(shareId);

if (!shareData) {
return new Response('Not found', { status: 404 });
}

return new Response(shareData, {
headers: {
'content-type': 'application/json',
},
});
}

export const config: Config = {
path: '/share/:shareId',
};
26 changes: 26 additions & 0 deletions apps/studio-next/src/netlify/functions/share-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getStore } from '@netlify/blobs';
import type { Config, Context } from '@netlify/functions';
import { randomUUID } from 'crypto';

export default async (req: Request, context: Context) => {
const share = getStore('share');
const shareId = randomUUID();

const state = await req.json();

await share.set(shareId, JSON.stringify({
URL: `${context.site.url }?share=${ shareId}`,
...state,
created: Date.now(),
}))

return new Response(shareId, {
headers: {
'content-type': 'text/plain',
},
});
};

export const config: Config = {
path: '/share',
}
30 changes: 21 additions & 9 deletions apps/studio-next/src/services/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export class ApplicationService extends AbstractService {
// subscribe to state to hide preloader
this.hidePreloader();

const { readOnly, url, base64 } =
const { readOnly, url, base64, share } =
this.svcs.navigationSvc.getUrlParameters();
// readOnly state should be only set to true when someone pass also url or base64 parameter
const isStrictReadonly = Boolean(readOnly && (url || base64));
// readOnly state should be only set to true when someone pass also url or base64 or share parameter
const isStrictReadonly = Boolean(readOnly && (url || base64 || share));

let error: any;
try {
await this.fetchResource(url, base64);
await this.fetchResource(url, base64, share);
} catch (err) {
error = err;
console.error(err);
Expand All @@ -37,18 +37,18 @@ export class ApplicationService extends AbstractService {
}

public async afterAppInit() {
const { readOnly, url, base64, redirectedFrom } =
const { readOnly, url, base64, share, redirectedFrom } =
this.svcs.navigationSvc.getUrlParameters();
const isStrictReadonly = Boolean(readOnly && (url || base64));
const isStrictReadonly = Boolean(readOnly && (url || base64 || share));

// show RedirectedModal modal if the redirectedFrom is set (only when readOnly state is set to false)
if (!isStrictReadonly && redirectedFrom) {
show(RedirectedModal);
}
}

private async fetchResource(url: string | null, base64: string | null) {
if (!url && !base64) {
private async fetchResource(url: string | null, base64: string | null, share: string | null) {
if (!url && !base64 && !share) {
return;
}

Expand All @@ -58,15 +58,27 @@ export class ApplicationService extends AbstractService {
content = await fetch(url).then((res) => res.text());
} else if (base64) {
content = this.svcs.formatSvc.decodeBase64(base64);
} else if (share) {
const response = await fetch(`/share/${share}`);
const data = await response.json();
content = data.content;
}

const language = this.svcs.formatSvc.retrieveLangauge(content);
const source = url || undefined;
let from = 'url';

if (base64) {
from = 'base64';
} else if (share) {
from = 'share';
}

updateFile('asyncapi', {
content,
language,
source,
from: url ? 'url' : 'base64',
from: from as 'url' | 'base64' | 'share',
});
await this.svcs.parserSvc.parse('asyncapi', content, { source });
}
Expand Down
Loading
Loading