on master help sign in
remove, validation and post feedback
commitaacd42c99b0a3f77558e74f129a83d01ffd3ef08 authorHunter Miller <hunter@hmiller.dev> dateFri 15 Jul 2022, 22:20 (4 years ago) parentf2a901a
10 files changed+191−55
desk/app/sphinx.hoon+41−7 desk/gen/validate.hoon+6 desk/lib/validate-url.hoon+18 desk/mar/remove.hoon+14 ui/src/components/ErrorMessage.tsx+26 ui/src/components/SearchList.tsx+9−5 ui/src/components/TagField.tsx+1 ui/src/pages/Post.tsx+59−40 ui/src/pages/Search.tsx+15−3 ui/src/types/sphinx.ts+2
desk/app/sphinx.hoon+41−7
@@ -1,10 +1,12 @@
11/- s=sphinx
22/+ gossip, default-agent, verb, dbug
33/+ *sphinx
4+/+ *validate-url
45/+ sift
56/+ m=metaphone
67/$ grab-listing %noun %directory-listing
78/$ grab-directory %noun %directory
9+=, de-purl:html
810^- agent:gall
911=>
1012 |%
@@ -103,10 +105,9 @@
103105 %declare
104106 =+ !<(=declare:s vase)
105107 di-abet:(publish declare di-core)
106− :: %declarations
107− :: =+ !<(declared=(list declare:s) vase)
108− :: =. di-core (roll declared publish)
109− :: cor
108+ %remove
109+ =+ !<(=hash:s vase)
110+ di-abet:di-remove:(di-abed:di-core hash)
110111 ==
111112 ++ publish
112113 |= [=declare:s core=_di-core]
@@ -123,7 +124,6 @@
123124 ?: (~(has by directory) hash.listing)
124125 ~& 'Listing already exists.'
125126 di-core
126− =. published (~(put by directory) hash.listing listing)
127127 (di-publish:(di-abed:core hash.listing) listing)
128128 --
129129::
@@ -201,16 +201,50 @@
201201 |=(=hash:s (~(got by directory) hash))
202202++ from-self =(our src):bowl
203203++ di-core
204− |_ [=listing:s =hash:s]
204+ |_ [=listing:s =hash:s gone=_|]
205205 ++ di-core .
206206 ++ di-abet
207− cor(directory (~(put by directory) hash listing))
207+ =. directory
208+ ?: gone (~(del by directory) hash)
209+ (~(put by directory) hash listing)
210+ cor
208211 ++ di-abed
209212 |= h=hash:s
210213 di-core(hash h, listing (~(gut by directory) h *listing:s))
214+ ++ di-remove
215+ =. gone &
216+ =/ keys (~(got by trail) hash)
217+ =. trail (~(del by trail) hash)
218+ =. lookup
219+ ^- lookup:s
220+ %- ~(uni by lookup)
221+ %- malt
222+ %+ turn
223+ keys
224+ |= =key:s
225+ :- key
226+ ^- (list entry:s)
227+ %+ skip
228+ (~(got by lookup) key)
229+ |=([h=hash:s =rank:s] =(h hash))
230+ =? published (~(has by published) hash)
231+ (~(del by published) hash)
232+ di-core
211233 ++ di-publish
212234 |= l=listing:s
213235 =. listing l
236+ ?> (lte (lent (trip title.post.l)) 77)
237+ ?> (lte (lent (trip description.post.l)) 256)
238+ ?> (lte (lent tags.post.l) 8)
239+ ?> (lte (lent (trip link.post.l)) 1.024)
240+ ?> (lte (lent (trip image.post.l)) 1.024)
241+ =. cor
242+ ?: =(image.post.l '') cor
243+ =/ image (validate image.post.l)
244+ cor
245+ =/ link (validate link.post.l)
246+ :: TODO check urls and sanitize
247+ =. published (~(put by directory) hash.listing listing)
214248 =. cor (emit (invent:gossip %directory-listing !>(listing)))
215249 =/ entries=lookup:s
216250 %- malt
desk/gen/validate.hoonnew file+6
@@ -0,0 +1,6 @@
1+/+ *validate-url
2+:- %say
3+|= $: [now=@da eny=@uv =beak]
4+ [[url=@t ~] ~]
5+ ==
6+noun+(validate url)
desk/lib/validate-url.hoonnew file+18
@@ -0,0 +1,18 @@
1+|%
2+++ validate
3+ |= url=@t
4+ ^- @t
5+ ~& url
6+ %+ rash url
7+ %+ cold url
8+ ;~ plug
9+ ;~ plug
10+ ;~(plug alf (star ;~(pose aln lus dot hep)) col fas fas)
11+ ;~(pose thor:de-purl:html ;~(pfix sig fed:ag))
12+ ==
13+ ;~ plug
14+ ;~(pose apat:de-purl:html (easy *pork:eyre))
15+ yque:de-purl:html
16+ ==
17+ ==
18+--
desk/mar/remove.hoonnew file+14
@@ -0,0 +1,14 @@
1+/- s=sphinx
2+/+ j=sphinx-json
3+|_ =hash:s
4+++ grad %noun
5+++ grow
6+ |%
7+ ++ noun hash
8+ --
9+++ grab
10+ |%
11+ ++ noun hash:s
12+ ++ json (se:dejs:format %uv)
13+ --
14+--
ui/src/components/ErrorMessage.tsxnew file+26
@@ -0,0 +1,26 @@
1+import cn from 'classnames';
2+import React from 'react';
3+import { LiteralUnion, RegisterOptions, useFormContext } from 'react-hook-form';
4+
5+type Validation = LiteralUnion<keyof RegisterOptions, string>;
6+
7+interface ErrorMessageProps<T> {
8+ field: keyof T;
9+ messages: Partial<Record<Validation, string>>;
10+ className?: string;
11+}
12+
13+export function ErrorMessage<T>({ field, messages, className }: ErrorMessageProps<T>) {
14+ const { formState } = useFormContext<T>();
15+ const error = formState.errors[field];
16+
17+ if (!error) {
18+ return null;
19+ }
20+
21+ return (
22+ <p className={cn('text-xs text-red-500', className)}>
23+ {messages[error.type as Validation]}
24+ </p>
25+ )
26+}
ui/src/components/SearchList.tsx+9−5
@@ -1,13 +1,16 @@
11import cn from 'classnames';
2−import React from 'react';
3−import { Listing } from '../types/sphinx';
2+import React, { useCallback } from 'react';
3+import { useMutation, useQueryClient } from 'react-query';
4+import api from '../api';
5+import { Listing, Remove } from '../types/sphinx';
46
57interface SearchListProps {
68 listings: Listing[];
9+ remove: (hash: string) => void;
710 className?: string;
811}
912
10−export const SearchList = ({ listings, className }: SearchListProps) => {
13+export const SearchList = ({ listings, remove, className }: SearchListProps) => {
1114 if (!listings) {
1215 return null;
1316 }
@@ -37,11 +40,12 @@ export const SearchList = ({ listings, className }: SearchListProps) => {
3740 </div>
3841 </a>
3942 <p className='mb-2 text-sm'>{l.post.description}</p>
40− <div className='flex font-mono text-xs text-zinc-500 space-x-3 opacity-0 group-hover:opacity-100 transition-opacity'>
43+ <div className='flex items-center w-full font-mono text-xs text-zinc-500 opacity-0 group-hover:opacity-100 transition-opacity'>
4144 <span><span className='font-semibold font-sans'>by:</span> {l.source}</span>
42− <time dateTime={(new Date(l.time)).toISOString()}>
45+ <time dateTime={(new Date(l.time)).toISOString()} className="ml-3">
4346 {(new Date(l.time)).toLocaleString()}
4447 </time>
48+ <button className='ml-auto font-semibold font-sans hover:text-rosy bg-transparent border-0' onClick={() => remove(l.hash)}>remove</button>
4549 </div>
4650 </div>
4751 </article>
ui/src/components/TagField.tsx+1
@@ -137,6 +137,7 @@ export const TagField = ({ tags, onTags, className }: TagFieldProps) => {
137137 padding: 0
138138 })
139139 }}
140+ isOptionDisabled={() => tags.length >= 8}
140141 inputValue={input}
141142 isClearable
142143 isMulti
ui/src/pages/Post.tsx+59−40
@@ -1,18 +1,26 @@
11import debounce from 'lodash.debounce';
22import React, { useCallback, useEffect, useRef, useState } from 'react';
3−import { useForm } from 'react-hook-form';
3+import { FormProvider, useForm } from 'react-hook-form';
44import { Link, useNavigate } from 'react-router-dom';
55import { MultiValue } from 'react-select';
66import api from '../api';
7+import { ErrorMessage } from '../components/ErrorMessage';
78import { Filter } from '../components/Filter';
89import { Option, TagField } from '../components/TagField';
910import { Declare, Post as PostForm } from '../types/sphinx';
1011
12+function errorMessages(length: number) {
13+ return {
14+ required: 'This field is required.',
15+ maxLength: `The maximum length is ${length}.`
16+ }
17+}
18+
1119export const Post = () => {
1220 const navigate = useNavigate();
1321 const [tags, setTags] = useState<MultiValue<Option>>([]);
1422 const [image, setImage] = useState<string>('');
15− const { register, watch, reset, setValue, handleSubmit } = useForm<PostForm>({
23+ const form = useForm<PostForm>({
1624 defaultValues: {
1725 title: '',
1826 type: 'other',
@@ -22,6 +30,8 @@ export const Post = () => {
2230 }
2331 });
2432
33+ const { register, watch, reset, setValue, handleSubmit, formState } = form;
34+
2535 const updateImg = useRef(debounce(setImage));
2636 const img = watch('image');
2737 const type = watch('type');
@@ -57,50 +67,59 @@ export const Post = () => {
5767 <header>
5868 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
5969 </header>
60− <form onSubmit={handleSubmit(onSubmit)}>
61− <div className='flex w-full space-x-6'>
62− <div className='flex-1 space-y-3'>
63− <div>
64− <label htmlFor='title' className='text-sm font-semibold'>Title</label>
65− <div className='flex items-center space-x-2'>
66− <input {...register('title', { required: true, maxLength: 80 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
67− <Filter selected={type} onSelect={(value) => setValue('type', value)} />
68− </div>
69− </div>
70− <div className='flex items-center space-x-6'>
71− <div className='flex-1 space-y-3'>
72− <div>
73− <label htmlFor='image' className='text-sm font-semibold'>Image</label>
74− <input type="url" {...register('image')} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
70+ <FormProvider {...form}>
71+ <form onSubmit={handleSubmit(onSubmit)}>
72+ <div className='flex w-full space-x-6'>
73+ <div className='flex-1 space-y-3'>
74+ <div>
75+ <label htmlFor='title' className='text-sm font-semibold'>Title</label>
76+ <div className='flex items-center space-x-2'>
77+ <input {...register('title', { required: true, maxLength: 77 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
78+ <Filter selected={type} onSelect={(value) => setValue('type', value)} />
7579 </div>
76− <div>
77− <label htmlFor='link' className='text-sm font-semibold'>Link</label>
78− <input type="url" {...register('link', { required: true })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~nocsyx-lassul/sphinx' />
80+ <ErrorMessage className='mt-1' field="title" messages={errorMessages(77)}/>
81+ </div>
82+ <div className='flex items-center space-x-6'>
83+ <div className='flex-1 space-y-3'>
84+ <div>
85+ <label htmlFor='image' className='text-sm font-semibold'>Image</label>
86+ <input type="url" {...register('image', {
87+ maxLength: 1024
88+ })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
89+ <ErrorMessage className='mt-1' field="image" messages={errorMessages(1024)}/>
90+ </div>
91+ <div>
92+ <label htmlFor='link' className='text-sm font-semibold'>Link</label>
93+ <input type="url" {...register('link', { required: true, maxLength: 1024 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~nocsyx-lassul/sphinx' />
94+ <ErrorMessage className='mt-1' field="link" messages={errorMessages(1024)} />
95+ </div>
7996 </div>
97+ <img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
8098 </div>
81− <img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
82− </div>
83− <div>
84− <label htmlFor='description' className='text-sm font-semibold'>Description</label>
85− <textarea {...register('description', { required: true, maxLength: 256 })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
86− </div>
87− <div>
88− <label className='text-sm font-semibold'>Tags</label>
89− <TagField tags={tags} onTags={setTags} />
90− </div>
91− <div className='pt-3'>
92− <div className='flex justify-between border-t border-zinc-300 py-3'>
93− <button type="button" className='flex items-center rounded-lg text-base font-semibold text-rosy bg-rosy/30 border-2 border-transparent hover:border-rosy leading-none py-2 px-3 transition-colors' onClick={() => navigate(-1)}>
94− Back to Search
95− </button>
96− <button type="submit" className='flex items-center rounded-lg text-base font-semibold text-linen bg-rosy border-2 border-transparent hover:border-linen/60 leading-none py-2 px-3 transition-colors'>
97− Publish
98− </button>
99+ <div>
100+ <label htmlFor='description' className='text-sm font-semibold'>Description</label>
101+ <textarea {...register('description', { required: true, maxLength: 256 })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
102+ <ErrorMessage className='mt-1' field="description" messages={errorMessages(256)} />
103+ </div>
104+ <div>
105+ <label className='text-sm font-semibold'>Tags</label>
106+ <TagField tags={tags} onTags={setTags} />
107+ {tags.length === 8 && <div className='text-mauve/50 text-xs mt-1'>8 tags maximum</div>}
108+ </div>
109+ <div className='pt-3'>
110+ <div className='flex justify-between border-t border-zinc-300 py-3'>
111+ <button type="button" className='flex items-center rounded-lg text-base font-semibold text-rosy bg-rosy/30 border-2 border-transparent hover:border-rosy leading-none py-2 px-3 transition-colors' onClick={() => navigate(-1)}>
112+ Back to Search
113+ </button>
114+ <button type="submit" className='flex items-center rounded-lg text-base font-semibold text-linen bg-rosy border-2 border-transparent hover:border-linen/60 leading-none py-2 px-3 transition-colors'>
115+ Publish
116+ </button>
117+ </div>
99118 </div>
100119 </div>
101120 </div>
102− </div>
103− </form>
121+ </form>
122+ </FormProvider>
104123 </>
105124 )
106125}
ui/src/pages/Search.tsx+15−3
@@ -3,8 +3,8 @@ import React, { useCallback, useRef, useState } from 'react';
33import debounce from 'lodash.debounce';
44import { SearchInput } from '../components/SearchInput';
55import { SearchList } from '../components/SearchList';
6−import { useQuery } from 'react-query';
7−import { PostFilter, PostType, Search as SearchType } from '../types/sphinx';
6+import { useMutation, useQuery, useQueryClient } from 'react-query';
7+import { PostFilter, PostType, Remove, Search as SearchType } from '../types/sphinx';
88import api from '../api';
99import { Link, useNavigate, useParams } from 'react-router-dom';
1010import { Paginator } from '../components/Paginator';
@@ -46,6 +46,18 @@ export const Search = () => {
4646 enabled: !!lookup,
4747 keepPreviousData: true
4848 });
49+ const queryClient = useQueryClient();
50+ const { mutate } = useMutation((hash: string) => {
51+ return api.poke<Remove>({
52+ app: 'sphinx',
53+ mark: 'remove',
54+ json: hash
55+ })
56+ }, {
57+ onSuccess: () => {
58+ queryClient.invalidateQueries(`lookup-${selected}-${lookup}-${size}-${start}`)
59+ }
60+ })
4961
5062 const total = data?.total || 0;
5163 const pages =
@@ -83,7 +95,7 @@ export const Search = () => {
8395 {lookup && data && <div className='flex justify-end border-t border-zinc-300'>
8496 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
8597 </div>}
86− {lookup && <SearchList listings={data?.listings || []} />}
98+ {lookup && <SearchList listings={data?.listings || []} remove={mutate} />}
8799 {data && pages > 1 && <div className='flex justify-end border-t border-zinc-300'>
88100 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
89101 </div>}
ui/src/types/sphinx.ts+2
@@ -7,6 +7,8 @@ export interface Declare {
77 post: Post
88}
99
10+export type Remove = string;
11+
1012export interface Post {
1113 title: string;
1214 type: PostType;