mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 06:13:11 +00:00
Merge pull request #10 from ashiskumarnaik/feat/ui
feat: implement optional cell icon with URL validation
This commit is contained in:
commit
cf5b480d35
1403
package-lock.json
generated
1403
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -8,7 +8,9 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
@ -68,21 +70,27 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"lovable-tagger": "^1.1.7",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
127
src/components/CreateCellDialog.test.tsx
Normal file
127
src/components/CreateCellDialog.test.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CypherImage } from './ui/CypherImage'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' ')
|
||||
}))
|
||||
|
||||
describe('Create Cell Without Icon - CypherImage Fallback', () => {
|
||||
it('shows fallback identicon when src is empty (simulating cell without icon)', () => {
|
||||
render(
|
||||
<CypherImage
|
||||
src=""
|
||||
alt="Test Cell"
|
||||
className="w-16 h-16"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Verify that the fallback identicon is rendered instead of an img tag
|
||||
const identicon = screen.getByTitle('Test Cell')
|
||||
expect(identicon).toBeInTheDocument()
|
||||
|
||||
// Check for the fallback identicon characteristics
|
||||
expect(identicon).toHaveClass('flex', 'items-center', 'justify-center')
|
||||
|
||||
// The fallback should contain the first letter of the alt text (cell name)
|
||||
const firstLetter = screen.getByText('T') // First letter of "Test Cell"
|
||||
expect(firstLetter).toBeInTheDocument()
|
||||
expect(firstLetter).toHaveClass('font-bold')
|
||||
|
||||
// Should not render an img element when src is empty
|
||||
const imgElement = screen.queryByRole('img')
|
||||
expect(imgElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows fallback identicon when src is undefined (simulating cell without icon)', () => {
|
||||
render(
|
||||
<CypherImage
|
||||
src={undefined}
|
||||
alt="Another Cell"
|
||||
className="w-16 h-16"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Verify that the fallback identicon is rendered
|
||||
const identicon = screen.getByTitle('Another Cell')
|
||||
expect(identicon).toBeInTheDocument()
|
||||
|
||||
// The fallback should contain the first letter of the alt text
|
||||
const firstLetter = screen.getByText('A') // First letter of "Another Cell"
|
||||
expect(firstLetter).toBeInTheDocument()
|
||||
|
||||
// Should not render an img element when src is undefined
|
||||
const imgElement = screen.queryByRole('img')
|
||||
expect(imgElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows fallback identicon with correct cyberpunk styling', () => {
|
||||
render(
|
||||
<CypherImage
|
||||
src=""
|
||||
alt="Cyberpunk Cell"
|
||||
className="w-16 h-16"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const identicon = screen.getByTitle('Cyberpunk Cell')
|
||||
|
||||
// Check for cyberpunk styling elements
|
||||
expect(identicon).toHaveStyle({ backgroundColor: '#0a1119' })
|
||||
|
||||
// Check that the first letter is rendered with appropriate styling
|
||||
const firstLetter = screen.getByText('C')
|
||||
expect(firstLetter).toHaveClass('relative', 'font-bold', 'cyberpunk-glow', 'z-10')
|
||||
})
|
||||
|
||||
it('renders normal img when src is provided (control test)', () => {
|
||||
render(
|
||||
<CypherImage
|
||||
src="https://example.com/valid-image.jpg"
|
||||
alt="Valid Image Cell"
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
)
|
||||
|
||||
// Should render an img element when src is provided
|
||||
const imgElement = screen.getByRole('img')
|
||||
expect(imgElement).toBeInTheDocument()
|
||||
expect(imgElement).toHaveAttribute('src', 'https://example.com/valid-image.jpg')
|
||||
expect(imgElement).toHaveAttribute('alt', 'Valid Image Cell')
|
||||
|
||||
// Should not show fallback identicon when image src is provided
|
||||
const identicon = screen.queryByTitle('Valid Image Cell')
|
||||
expect(identicon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('generates unique fallbacks for different cell names', () => {
|
||||
const { rerender } = render(
|
||||
<CypherImage
|
||||
src=""
|
||||
alt="Alpha Cell"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const alphaLetter = screen.getByText('A')
|
||||
expect(alphaLetter).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<CypherImage
|
||||
src=""
|
||||
alt="Beta Cell"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const betaLetter = screen.getByText('B')
|
||||
expect(betaLetter).toBeInTheDocument()
|
||||
|
||||
// Alpha should no longer be present
|
||||
expect(screen.queryByText('A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -23,16 +23,24 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { urlLoads } from "@/lib/utils/urlLoads";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(3, "Title must be at least 3 characters").max(50, "Title must be less than 50 characters"),
|
||||
description: z.string().min(10, "Description must be at least 10 characters").max(200, "Description must be less than 200 characters"),
|
||||
icon: z.string().url("Please enter a valid URL for the icon"),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || val.length === 0 || URL.canParse(val), {
|
||||
message: "Must be a valid URL"
|
||||
}),
|
||||
});
|
||||
|
||||
export function CreateCellDialog() {
|
||||
const { createCell, isPostingCell } = useForum();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@ -40,12 +48,25 @@ export function CreateCellDialog() {
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
icon: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const cell = await createCell(values.title, values.description, values.icon);
|
||||
// Validate icon URL if provided
|
||||
if (values.icon && values.icon.trim()) {
|
||||
const ok = await urlLoads(values.icon, 5000);
|
||||
if (!ok) {
|
||||
toast({
|
||||
title: "Icon URL Error",
|
||||
description: "Icon URL could not be loaded. Please check the URL and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cell = await createCell(values.title, values.description, values.icon || undefined);
|
||||
if (cell) {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
@ -100,12 +121,13 @@ export function CreateCellDialog() {
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Icon URL</FormLabel>
|
||||
<FormLabel>Icon URL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter icon URL"
|
||||
placeholder="Enter icon URL (optional)"
|
||||
type="url"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
disabled={isPostingCell}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CypherImageProps = {
|
||||
src: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fallbackClassName?: string;
|
||||
@ -25,14 +25,14 @@ export function CypherImage({
|
||||
|
||||
// Generate a seed based on the alt text or src to create consistent fallbacks for the same resource
|
||||
const seed = generateUniqueFallback ?
|
||||
(alt || src).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
|
||||
(alt || src || 'fallback').split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
|
||||
|
||||
// Handle image load error
|
||||
const handleError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
if (imageError) {
|
||||
if (imageError || !src || src.trim() === '') {
|
||||
// Generate some values based on the seed for variety
|
||||
const hue = (seed % 60) + 140; // Cyan-ish colors (140-200)
|
||||
const gridSize = (seed % 8) + 8; // 8-16px
|
||||
|
||||
@ -40,7 +40,7 @@ interface ForumContextType {
|
||||
createComment: (postId: string, content: string) => Promise<Comment | null>;
|
||||
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
|
||||
createCell: (name: string, description: string, icon?: string) => Promise<Cell | null>;
|
||||
refreshData: () => Promise<void>;
|
||||
moderatePost: (
|
||||
cellId: string,
|
||||
@ -223,7 +223,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleCreateCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
|
||||
const handleCreateCell = async (name: string, description: string, icon?: string): Promise<Cell | null> => {
|
||||
setIsPostingCell(true);
|
||||
const result = await createCell(
|
||||
name,
|
||||
|
||||
@ -214,7 +214,7 @@ export const createComment = async (
|
||||
export const createCell = async (
|
||||
name: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
icon: string | undefined,
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
@ -243,7 +243,7 @@ export const createCell = async (
|
||||
id: cellId,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
...(icon && { icon }),
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
@ -17,7 +17,7 @@ export const transformCell = (
|
||||
id: cellMessage.id,
|
||||
name: cellMessage.name,
|
||||
description: cellMessage.description,
|
||||
icon: cellMessage.icon,
|
||||
icon: cellMessage.icon || '',
|
||||
signature: cellMessage.signature,
|
||||
browserPubKey: cellMessage.browserPubKey
|
||||
};
|
||||
|
||||
@ -21,6 +21,6 @@ export interface ForumContextType {
|
||||
createComment: (postId: string, content: string) => Promise<Comment | null>;
|
||||
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
|
||||
createCell: (name: string, description: string, icon?: string) => Promise<Cell | null>;
|
||||
refreshData: () => Promise<void>;
|
||||
}
|
||||
125
src/lib/utils/urlLoads.test.ts
Normal file
125
src/lib/utils/urlLoads.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { urlLoads } from './urlLoads'
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
describe('urlLoads', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
it('returns false on 404', async () => {
|
||||
// Mock fetch to return a 404 response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const result = await urlLoads('https://example.com/nonexistent.jpg')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg', {
|
||||
method: 'HEAD',
|
||||
signal: expect.any(AbortSignal),
|
||||
cache: 'no-cache'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns true on successful response (200)', async () => {
|
||||
// Mock fetch to return a successful response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await urlLoads('https://example.com/image.jpg')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg', {
|
||||
method: 'HEAD',
|
||||
signal: expect.any(AbortSignal),
|
||||
cache: 'no-cache'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
// Mock fetch to throw a network error
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await urlLoads('https://example.com/image.jpg')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on abort signal', async () => {
|
||||
// Mock fetch to reject with AbortError (simulating timeout)
|
||||
const abortError = new Error('The operation was aborted')
|
||||
abortError.name = 'AbortError'
|
||||
mockFetch.mockRejectedValueOnce(abortError)
|
||||
|
||||
const result = await urlLoads('https://example.com/image.jpg', 1000)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for other successful status codes (e.g., 201, 301)', async () => {
|
||||
// Test 201 Created
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
})
|
||||
|
||||
const result201 = await urlLoads('https://example.com/created.jpg')
|
||||
expect(result201).toBe(true)
|
||||
|
||||
// Test 301 Moved Permanently
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 301,
|
||||
})
|
||||
|
||||
const result301 = await urlLoads('https://example.com/moved.jpg')
|
||||
expect(result301).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for other error status codes (e.g., 403, 500)', async () => {
|
||||
// Test 403 Forbidden
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
})
|
||||
|
||||
const result403 = await urlLoads('https://example.com/forbidden.jpg')
|
||||
expect(result403).toBe(false)
|
||||
|
||||
// Test 500 Internal Server Error
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
const result500 = await urlLoads('https://example.com/error.jpg')
|
||||
expect(result500).toBe(false)
|
||||
})
|
||||
|
||||
it('calls fetch with correct parameters', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
await urlLoads('https://example.com/test.jpg', 3000)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/test.jpg', {
|
||||
method: 'HEAD',
|
||||
signal: expect.any(AbortSignal),
|
||||
cache: 'no-cache'
|
||||
})
|
||||
})
|
||||
})
|
||||
20
src/lib/utils/urlLoads.ts
Normal file
20
src/lib/utils/urlLoads.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Utility to check if a URL loads successfully within a timeout
|
||||
*/
|
||||
export async function urlLoads(url: string, timeoutMs: number = 5000): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ export function cellToMessage(cell: Cell, sender: string): CellMessage {
|
||||
id: cell.id,
|
||||
name: cell.name,
|
||||
description: cell.description,
|
||||
icon: cell.icon
|
||||
...(cell.icon && { icon: cell.icon })
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ export function messageToCell(message: CellMessage): Cell {
|
||||
id: message.id,
|
||||
name: message.name,
|
||||
description: message.description,
|
||||
icon: message.icon
|
||||
icon: message.icon || ''
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ export interface CellMessage extends BaseMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@ -16,7 +16,7 @@ export interface Cell {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
icon?: string;
|
||||
signature?: string; // Message signature
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
}
|
||||
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user