on master help sign in
remove, validation and post feedback
aacd42cHunter Miller
desk/app/sphinx.hoon+41−7
@@ -1,10 +1,12 @@
1/- s=sphinx
1/- s=sphinx
2/+ gossip, default-agent, verb, dbug
2/+ gossip, default-agent, verb, dbug
3/+ *sphinx
3/+ *sphinx
4+/+ *validate-url
4/+ sift
5/+ sift
5/+ m=metaphone
6/+ m=metaphone
6/$ grab-listing %noun %directory-listing
7/$ grab-listing %noun %directory-listing
7/$ grab-directory %noun %directory
8/$ grab-directory %noun %directory
9+=, de-purl:html
8^- agent:gall
10^- agent:gall
9=>
11=>
10 |%
12 |%
@@ -103,10 +105,9 @@
103 %declare
105 %declare
104 =+ !<(=declare:s vase)
106 =+ !<(=declare:s vase)
105 di-abet:(publish declare di-core)
107 di-abet:(publish declare di-core)
106− :: %declarations
108+ %remove
107− :: =+ !<(declared=(list declare:s) vase)
109+ =+ !<(=hash:s vase)
108− :: =. di-core (roll declared publish)
110+ di-abet:di-remove:(di-abed:di-core hash)
109− :: cor
110 ==
111 ==
111 ++ publish
112 ++ publish
112 |= [=declare:s core=_di-core]
113 |= [=declare:s core=_di-core]
@@ -123,7 +124,6 @@
123 ?: (~(has by directory) hash.listing)
124 ?: (~(has by directory) hash.listing)
124 ~& 'Listing already exists.'
125 ~& 'Listing already exists.'
125 di-core
126 di-core
126− =. published (~(put by directory) hash.listing listing)
127 (di-publish:(di-abed:core hash.listing) listing)
127 (di-publish:(di-abed:core hash.listing) listing)
128 --
128 --
129::
129::
@@ -201,16 +201,50 @@
201 |=(=hash:s (~(got by directory) hash))
201 |=(=hash:s (~(got by directory) hash))
202++ from-self =(our src):bowl
202++ from-self =(our src):bowl
203++ di-core
203++ di-core
204− |_ [=listing:s =hash:s]
204+ |_ [=listing:s =hash:s gone=_|]
205 ++ di-core .
205 ++ di-core .
206 ++ di-abet
206 ++ di-abet
207+ =. directory
208+ ?: gone (~(del by directory) hash)
207− cor(directory (~(put by directory) hash listing))
209+ (~(put by directory) hash listing)
210+ cor
208 ++ di-abed
211 ++ di-abed
209 |= h=hash:s
212 |= h=hash:s
210 di-core(hash h, listing (~(gut by directory) h *listing:s))
213 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
211 ++ di-publish
233 ++ di-publish
212 |= l=listing:s
234 |= l=listing:s
213 =. listing l
235 =. 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)
214 =. cor (emit (invent:gossip %directory-listing !>(listing)))
248 =. cor (emit (invent:gossip %directory-listing !>(listing)))
215 =/ entries=lookup:s
249 =/ entries=lookup:s
216 %- malt
250 %- 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 @@
1import cn from 'classnames';
1import cn from 'classnames';
2−import React from 'react';
2+import React, { useCallback } from 'react';
3+import { useMutation, useQueryClient } from 'react-query';
4+import api from '../api';
3−import { Listing } from '../types/sphinx';
5+import { Listing, Remove } from '../types/sphinx';
4
6
5interface SearchListProps {
7interface SearchListProps {
6 listings: Listing[];
8 listings: Listing[];
9+ remove: (hash: string) => void;
7 className?: string;
10 className?: string;
8}
11}
9
12
10−export const SearchList = ({ listings, className }: SearchListProps) => {
13+export const SearchList = ({ listings, remove, className }: SearchListProps) => {
11 if (!listings) {
14 if (!listings) {
12 return null;
15 return null;
13 }
16 }
@@ -37,11 +40,12 @@ export const SearchList = ({ listings, className }: SearchListProps) => {
37 </div>
40 </div>
38 </a>
41 </a>
39 <p className='mb-2 text-sm'>{l.post.description}</p>
42 <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'>
41 <span><span className='font-semibold font-sans'>by:</span> {l.source}</span>
44 <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">
43 {(new Date(l.time)).toLocaleString()}
46 {(new Date(l.time)).toLocaleString()}
44 </time>
47 </time>
48+ <button className='ml-auto font-semibold font-sans hover:text-rosy bg-transparent border-0' onClick={() => remove(l.hash)}>remove</button>
45 </div>
49 </div>
46 </div>
50 </div>
47 </article>
51 </article>
ui/src/components/TagField.tsx+1
@@ -137,6 +137,7 @@ export const TagField = ({ tags, onTags, className }: TagFieldProps) => {
137 padding: 0
137 padding: 0
138 })
138 })
139 }}
139 }}
140+ isOptionDisabled={() => tags.length >= 8}
140 inputValue={input}
141 inputValue={input}
141 isClearable
142 isClearable
142 isMulti
143 isMulti
ui/src/pages/Post.tsx+59−40
@@ -1,18 +1,26 @@
1import debounce from 'lodash.debounce';
1import debounce from 'lodash.debounce';
2import React, { useCallback, useEffect, useRef, useState } from 'react';
2import React, { useCallback, useEffect, useRef, useState } from 'react';
3−import { useForm } from 'react-hook-form';
3+import { FormProvider, useForm } from 'react-hook-form';
4import { Link, useNavigate } from 'react-router-dom';
4import { Link, useNavigate } from 'react-router-dom';
5import { MultiValue } from 'react-select';
5import { MultiValue } from 'react-select';
6import api from '../api';
6import api from '../api';
7+import { ErrorMessage } from '../components/ErrorMessage';
7import { Filter } from '../components/Filter';
8import { Filter } from '../components/Filter';
8import { Option, TagField } from '../components/TagField';
9import { Option, TagField } from '../components/TagField';
9import { Declare, Post as PostForm } from '../types/sphinx';
10import { Declare, Post as PostForm } from '../types/sphinx';
10
11
12+function errorMessages(length: number) {
13+ return {
14+ required: 'This field is required.',
15+ maxLength: `The maximum length is ${length}.`
16+ }
17+}
18+
11export const Post = () => {
19export const Post = () => {
12 const navigate = useNavigate();
20 const navigate = useNavigate();
13 const [tags, setTags] = useState<MultiValue<Option>>([]);
21 const [tags, setTags] = useState<MultiValue<Option>>([]);
14 const [image, setImage] = useState<string>('');
22 const [image, setImage] = useState<string>('');
15− const { register, watch, reset, setValue, handleSubmit } = useForm<PostForm>({
23+ const form = useForm<PostForm>({
16 defaultValues: {
24 defaultValues: {
17 title: '',
25 title: '',
18 type: 'other',
26 type: 'other',
@@ -22,6 +30,8 @@ export const Post = () => {
22 }
30 }
23 });
31 });
24
32
33+ const { register, watch, reset, setValue, handleSubmit, formState } = form;
34+
25 const updateImg = useRef(debounce(setImage));
35 const updateImg = useRef(debounce(setImage));
26 const img = watch('image');
36 const img = watch('image');
27 const type = watch('type');
37 const type = watch('type');
@@ -57,50 +67,59 @@ export const Post = () => {
57 <header>
67 <header>
58 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
68 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
59 </header>
69 </header>
60− <form onSubmit={handleSubmit(onSubmit)}>
70+ <FormProvider {...form}>
61− <div className='flex w-full space-x-6'>
71+ <form onSubmit={handleSubmit(onSubmit)}>
62− <div className='flex-1 space-y-3'>
72+ <div className='flex w-full space-x-6'>
73+ <div className='flex-1 space-y-3'>
63− <div>
74+ <div>
64− <label htmlFor='title' className='text-sm font-semibold'>Title</label>
75+ <label htmlFor='title' className='text-sm font-semibold'>Title</label>
65− <div className='flex items-center space-x-2'>
76+ <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'/>
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'/>
67− <Filter selected={type} onSelect={(value) => setValue('type', value)} />
78+ <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/...' />
75 </div>
79 </div>
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)}/>
76− <div>
90+ </div>
91+ <div>
77− <label htmlFor='link' className='text-sm font-semibold'>Link</label>
92+ <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' />
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>
79 </div>
96 </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} />
80 </div>
98 </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>
99+ <div>
83− <div>
84− <label htmlFor='description' className='text-sm font-semibold'>Description</label>
100+ <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' />
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)} />
86− </div>
103+ </div>
87− <div>
104+ <div>
88− <label className='text-sm font-semibold'>Tags</label>
105+ <label className='text-sm font-semibold'>Tags</label>
89− <TagField tags={tags} onTags={setTags} />
106+ <TagField tags={tags} onTags={setTags} />
90− </div>
107+ {tags.length === 8 && <div className='text-mauve/50 text-xs mt-1'>8 tags maximum</div>}
108+ </div>
91− <div className='pt-3'>
109+ <div className='pt-3'>
92− <div className='flex justify-between border-t border-zinc-300 py-3'>
110+ <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)}>
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)}>
94− Back to Search
112+ Back to Search
95− </button>
113+ </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'>
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'>
97− Publish
115+ Publish
98− </button>
116+ </button>
117+ </div>
99 </div>
118 </div>
100 </div>
119 </div>
101 </div>
120 </div>
102− </div>
121+ </form>
103− </form>
122+ </FormProvider>
104 </>
123 </>
105 )
124 )
106}
125}
ui/src/pages/Search.tsx+15−3
@@ -3,8 +3,8 @@ import React, { useCallback, useRef, useState } from 'react';
3import debounce from 'lodash.debounce';
3import debounce from 'lodash.debounce';
4import { SearchInput } from '../components/SearchInput';
4import { SearchInput } from '../components/SearchInput';
5import { SearchList } from '../components/SearchList';
5import { SearchList } from '../components/SearchList';
6−import { useQuery } from 'react-query';
6+import { useMutation, useQuery, useQueryClient } from 'react-query';
7−import { PostFilter, PostType, Search as SearchType } from '../types/sphinx';
7+import { PostFilter, PostType, Remove, Search as SearchType } from '../types/sphinx';
8import api from '../api';
8import api from '../api';
9import { Link, useNavigate, useParams } from 'react-router-dom';
9import { Link, useNavigate, useParams } from 'react-router-dom';
10import { Paginator } from '../components/Paginator';
10import { Paginator } from '../components/Paginator';
@@ -46,6 +46,18 @@ export const Search = () => {
46 enabled: !!lookup,
46 enabled: !!lookup,
47 keepPreviousData: true
47 keepPreviousData: true
48 });
48 });
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+ })
49
61
50 const total = data?.total || 0;
62 const total = data?.total || 0;
51 const pages =
63 const pages =
@@ -83,7 +95,7 @@ export const Search = () => {
83 {lookup && data && <div className='flex justify-end border-t border-zinc-300'>
95 {lookup && data && <div className='flex justify-end border-t border-zinc-300'>
84 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
96 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
85 </div>}
97 </div>}
86− {lookup && <SearchList listings={data?.listings || []} />}
98+ {lookup && <SearchList listings={data?.listings || []} remove={mutate} />}
87 {data && pages > 1 && <div className='flex justify-end border-t border-zinc-300'>
99 {data && pages > 1 && <div className='flex justify-end border-t border-zinc-300'>
88 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
100 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
89 </div>}
101 </div>}
ui/src/types/sphinx.ts+2
@@ -7,6 +7,8 @@ export interface Declare {
7 post: Post
7 post: Post
8}
8}
9
9
10+export type Remove = string;
11+
10export interface Post {
12export interface Post {
11 title: string;
13 title: string;
12 type: PostType;
14 type: PostType;