additional writable columns

+ stacked columns
This commit is contained in:
Johannes Lötzsch 2022-03-13 23:37:29 +01:00
parent 57fa871354
commit 9114c0fa03
7 changed files with 182 additions and 54 deletions

View File

@ -2,11 +2,24 @@
(:require [clojure.spec.alpha :as s] (:require [clojure.spec.alpha :as s]
[specialist-server.type :as t])) [specialist-server.type :as t]))
(s/def :xt/id t/string)
(s/def ::rw_contacted (s/nilable t/string))
(s/def ::rw_contact_replied (s/nilable t/string))
(s/def ::rw_offer_occupied (s/nilable t/string))
(s/def ::rw_note (s/nilable t/string)) (s/def ::rw_note (s/nilable t/string))
(s/def ::record (s/keys :req-un [::rw_note])) (s/def ::record (s/keys :opt-un [:xt/id ::rw_contacted ::rw_contact_replied ::rw_offer_occupied ::rw_note]))
(defn add_missing
"TODO: provide this as a reusable feature of specialist-server"
[record]
(-> record
(update :rw_contacted identity)
(update :rw_contact_replied identity)
(update :rw_offer_occupied identity)
(update :rw_note identity)))
(defn db->graphql [record] (defn db->graphql [record]
(some-> record (some-> record
;(select-keys [:xt/id :rw_note]) (add_missing)
(assoc :id (:xt/id record)))) (assoc :id (:xt/id record))))

View File

@ -4,9 +4,11 @@
(s/def ::rowId t/string) (s/def ::rowId t/string)
(s/def ::columnId t/string) (s/def ::columnId t/string)
(s/def ::value t/string) (s/def ::value_string (s/nilable t/string))
(s/def ::value_boolean (s/nilable t/boolean))
(t/defobject OnEditComplete {:name "OnEditComplete" :kind t/input-object-kind :description "https://reactdatagrid.io/docs/api-reference#props-onEditComplete"} (t/defobject OnEditCompleteByType {:name "OnEditCompleteByType" :kind t/input-object-kind :description "https://reactdatagrid.io/docs/api-reference#props-onEditComplete"}
:req-un [::rowId ::columnId ::value]) :req-un [::rowId ::columnId
::value_string ::value_boolean])
(s/def ::onEditComplete OnEditComplete) (s/def ::onEditCompleteByType OnEditCompleteByType)

View File

@ -7,14 +7,9 @@
[beherbergung.model.offer-rw :as offer-rw :refer [db->graphql]] [beherbergung.model.offer-rw :as offer-rw :refer [db->graphql]]
[clojure.edn])) [clojure.edn]))
(s/def :xt/id t/string)
(s/def ::rw_note (s/nilable t/string))
(s/def ::rw (s/keys :req-un [:xt/id
::rw_note]))
(s/fdef get_rw (s/fdef get_rw
:args (s/tuple map? (s/keys :req-un [::auth/auth]) map? map?) :args (s/tuple map? (s/keys :req-un [::auth/auth]) map? map?)
:ret (s/nilable (s/* ::rw))) :ret (s/nilable (s/* ::offer-rw/record)))
(defn get_rw (defn get_rw
[_node opt ctx _info] [_node opt ctx _info]

View File

@ -9,19 +9,27 @@
[clojure.edn])) [clojure.edn]))
(s/fdef write_rw (s/fdef write_rw
:args (s/tuple map? (s/keys :req-un [::auth/auth ::oneditcomplete/onEditComplete]) map? map?) :args (s/tuple map? (s/keys :req-un [::auth/auth ::oneditcomplete/onEditCompleteByType]) map? map?)
:ret (s/nilable t/boolean)) :ret (s/nilable t/boolean))
(defn write_rw (defn write_rw
[_node opt ctx _info] [_node opt ctx _info]
(let [{:keys [tx_sync tx-committed?]} (:db_ctx ctx) (let [{:keys [tx-fn-put tx-fn-call sync]} (:db_ctx ctx)
[ngo:id] (auth+role->entity ctx (:auth opt) ::ngo/record)] [ngo:id] (auth+role->entity ctx (:auth opt) ::ngo/record)
(boolean (when ngo:id tx_result (when ngo:id
(let [{:keys [value columnId rowId]} (:onEditComplete opt) (let [{:keys [rowId columnId
t (tx_sync [;; TODO use :xtdb.api/match to verify we update the latest version instead of last write wins value_boolean value_string]} (:onEditCompleteByType opt)
[:xtdb.api/put {:xt/id rowId value (or value_boolean value_string)
:xt/spec ::offer-rw/record doc {(keyword columnId) value}]
(keyword columnId) value}]])] (tx-fn-put :write_rw
(tx-committed? t)))))) '(fn [ctx eid doc]
(let [db (xtdb.api/db ctx)
entity (xtdb.api/entity db eid)]
[[:xtdb.api/put (assoc (merge entity doc)
:xt/id eid
:xt/spec ::offer-rw/record)]])))
(tx-fn-call :write_rw rowId doc)))]
(sync)
(boolean (:xtdb.api/tx-id tx_result))))
(s/def ::get_offers (t/resolver #'write_rw)) (s/def ::get_offers (t/resolver #'write_rw))

View File

@ -26,6 +26,29 @@ export type Auth = {
password: Scalars['String']; password: Scalars['String'];
}; };
/** If this server supports mutation, the type that mutation operations will be rooted at. */
export type MutationType = {
__typename?: 'MutationType';
write_rw?: Maybe<Scalars['Boolean']>;
};
/** If this server supports mutation, the type that mutation operations will be rooted at. */
export type MutationTypeWrite_RwArgs = {
auth: Auth;
onEditComplete: OnEditComplete;
};
/** https://reactdatagrid.io/docs/api-reference#props-onEditComplete */
export type OnEditComplete = {
/** Self descriptive. */
columnId: Scalars['String'];
/** Self descriptive. */
rowId: Scalars['String'];
/** Self descriptive. */
value: Scalars['String'];
};
/** The type that query operations will be rooted at. */ /** The type that query operations will be rooted at. */
export type QueryType = { export type QueryType = {
__typename?: 'QueryType'; __typename?: 'QueryType';
@ -95,8 +118,12 @@ export type Get_Offers = {
export type Get_Rw = { export type Get_Rw = {
__typename?: 'get_rw'; __typename?: 'get_rw';
id?: Maybe<Scalars['String']>; /** Self descriptive. */
id: Scalars['String'];
rw_contact_replied?: Maybe<Scalars['String']>;
rw_contacted?: Maybe<Scalars['String']>;
rw_note?: Maybe<Scalars['String']>; rw_note?: Maybe<Scalars['String']>;
rw_offer_occupied?: Maybe<Scalars['String']>;
}; };
/** For a username+password get a jwt containing the login:id */ /** For a username+password get a jwt containing the login:id */
@ -124,7 +151,7 @@ export type GetRwQueryVariables = Exact<{
}>; }>;
export type GetRwQuery = { __typename?: 'QueryType', get_rw?: Array<{ __typename?: 'get_rw', id?: string | null, rw_note?: string | null }> | null }; export type GetRwQuery = { __typename?: 'QueryType', get_rw?: Array<{ __typename?: 'get_rw', id: string, rw_contacted?: string | null, rw_contact_replied?: string | null, rw_offer_occupied?: string | null, rw_note?: string | null }> | null };
export const LoginDocument = ` export const LoginDocument = `
@ -185,6 +212,9 @@ export const GetRwDocument = `
query GetRw($auth: Auth!) { query GetRw($auth: Auth!) {
get_rw(auth: $auth) { get_rw(auth: $auth) {
id id
rw_contacted
rw_contact_replied
rw_offer_occupied
rw_note rw_note
} }
} }

View File

@ -32,6 +32,9 @@ export const get_rw = gql`
query GetRw($auth: Auth!) { query GetRw($auth: Auth!) {
get_rw(auth: $auth) { get_rw(auth: $auth) {
id id
rw_contacted
rw_contact_replied
rw_offer_occupied
rw_note rw_note
} }
}` }`

View File

@ -1,21 +1,22 @@
import React, {ReactNode, useCallback} from 'react' import React, {ReactNode, useCallback} from 'react'
import {CheckBoxOutlineBlank, CheckBox} from '@mui/icons-material'; import {CheckBoxOutlineBlank, CheckBox} from '@mui/icons-material'
import '@inovua/reactdatagrid-community/index.css' import '@inovua/reactdatagrid-community/index.css'
import DataGrid from '@inovua/reactdatagrid-community' import DataGrid from '@inovua/reactdatagrid-community'
import {TypeColumn, TypeFilterValue, TypeSingleFilterValue} from "@inovua/reactdatagrid-community/types"
import DateFilter from '@inovua/reactdatagrid-community/DateFilter' import DateFilter from '@inovua/reactdatagrid-community/DateFilter'
import StringFilter from '@inovua/reactdatagrid-community/StringFilter' import StringFilter from '@inovua/reactdatagrid-community/StringFilter'
import BoolFilter from '@inovua/reactdatagrid-community/BoolFilter' import BoolFilter from '@inovua/reactdatagrid-community/BoolFilter'
import {GetOffersQuery} from "../../codegen/generates"; import NumberFilter from "@inovua/reactdatagrid-community/NumberFilter"
import {TypeColumn, TypeFilterValue, TypeSingleFilterValue} from "@inovua/reactdatagrid-community/types"; import BoolEditor from '@inovua/reactdatagrid-community/BoolEditor'
import NumberFilter from "@inovua/reactdatagrid-community/NumberFilter"; import {GetOffersQuery} from "../../codegen/generates"
import moment from "moment"; import moment from "moment"
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next"
import {resources} from '../../i18n/config'; import {resources} from '../../i18n/config'
import {Box} from "@mui/material"; import {Box} from "@mui/material"
import { fetcher } from '../../codegen/fetcher' import { fetcher } from '../../codegen/fetcher'
import { useAuthStore, AuthState } from '../Login' import { useAuthStore, AuthState } from '../Login'
@ -34,6 +35,7 @@ interface ColumnRaw {
type: string; type: string;
editable: boolean; editable: boolean;
defaultWidth: number; defaultWidth: number;
group: string;
} }
/** /**
@ -48,14 +50,47 @@ const makeColumnDefinition = (data: any) => Object.keys(data)
})) }))
const columnsRaw: Partial<ColumnRaw>[] = [ const columnsRaw: Partial<ColumnRaw>[] = [
{
"name": "rw_contacted",
"group": "contactStatus",
"header": "Asked",
"type": "boolean",
"editable": true,
"defaultWidth": 85
},
{
"name": "rw_contact_replied",
"group": "contactStatus",
"header": "Answered",
"type": "boolean",
"editable": true,
"defaultWidth": 110
},
{
"name": "rw_offer_occupied",
"group": "offerStatus",
"header": "Occupied",
"type": "boolean",
"editable": true,
"defaultWidth": 110
},
{
"name": "rw_note",
"header": "Our notes",
"type": "string",
"editable": true,
"defaultWidth": 400
},
{ {
"name": "place_country", "name": "place_country",
"group": "locationCoarse",
"header": "Country", "header": "Country",
"type": "string", "type": "string",
"defaultWidth": 10 "defaultWidth": 10
}, },
{ {
"name": "place_city", "name": "place_city",
"group": "locationCoarse",
"header": "City", "header": "City",
"type": "string" "type": "string"
}, },
@ -66,45 +101,43 @@ const columnsRaw: Partial<ColumnRaw>[] = [
}, },
{ {
"name": "time_from_str", "name": "time_from_str",
"group": "time",
"header": "From", "header": "From",
"type": "date", "type": "date",
"defaultWidth": 90 "defaultWidth": 90
}, },
{ {
"name": "time_duration_str", "name": "time_duration_str",
"group": "time",
"header": "Duration", "header": "Duration",
"type": "string" "type": "string"
}, },
{ {
"name": "languages", "name": "languages",
"header": "languages", "header": "Languages",
"type": "object", "type": "object",
"defaultWidth": 200 "defaultWidth": 200
}, },
{ {
"name": "accessible", "name": "accessible",
"header": "accessible", "group": "features",
"header": "Accessible",
"type": "boolean", "type": "boolean",
"defaultWidth": 80 "defaultWidth": 120
}, },
{ {
"name": "animals_allowed", "name": "animals_allowed",
"header": "allows animals", "group": "animals",
"header": "Allowed",
"type": "boolean", "type": "boolean",
"defaultWidth": 80 "defaultWidth": 100
}, },
{ {
"name": "animals_present", "name": "animals_present",
"header": "has animals", "group": "animals",
"header": "Present",
"type": "boolean", "type": "boolean",
"defaultWidth": 80 "defaultWidth": 95
},
{
"name": "rw_note",
"header": "Our notes",
"type": "string",
"editable": true,
"defaultWidth": 400
}, },
{ {
"name": "note", "name": "note",
@ -114,44 +147,67 @@ const columnsRaw: Partial<ColumnRaw>[] = [
}, },
{ {
"name": "contact_name_full", "name": "contact_name_full",
"group": "contact",
"header": "Name", "header": "Name",
"type": "string" "type": "string"
}, },
{ {
"name": "contact_phone", "name": "contact_phone",
"group": "contact",
"header": "Phone", "header": "Phone",
"type": "string" "type": "string"
}, },
{ {
"name": "contact_email", "name": "contact_email",
"group": "contact",
"header": "EMail", "header": "EMail",
"type": "string" "type": "string"
}, },
{ {
"name": "place_street", "name": "place_street",
"group": "address",
"header": "Street", "header": "Street",
"type": "string" "type": "string"
}, },
{ {
"name": "place_street_number", "name": "place_street_number",
"header": "Street number", "group": "address",
"header": "Number",
"type": "string", "type": "string",
"defaultWidth": 80 "defaultWidth": 100
}, },
{ {
"name": "place_zip", "name": "place_zip",
"group": "address",
"header": "Zip", "header": "Zip",
"type": "string", "type": "string",
"defaultWidth": 80 "defaultWidth": 80
}, },
] ]
const groups = [
{ name: 'contactStatus', header: 'Contact Status' },
{ name: 'offerStatus', header: 'Offer Status' },
{ name: 'locationCoarse', header: 'Location' },
{ name: 'time', header: 'Time' },
{ name: 'features', header: 'Limitations / Features' },
{ name: 'animals', group: 'features', header: 'Animals' },
{ name: 'contact', header: 'Contact' },
{ name: 'address', group: 'contact', header: 'Address' },
]
const filterMappings = { const filterMappings = {
string: StringFilter, string: StringFilter,
boolean: BoolFilter, boolean: BoolFilter,
number: NumberFilter, number: NumberFilter,
date: DateFilter, date: DateFilter,
} }
const editorMappings = {
string: null,
boolean: BoolEditor,
number: null,
date: null
}
const operatorsForType = { const operatorsForType = {
number: 'gte', number: 'gte',
string: 'contains', string: 'contains',
@ -164,6 +220,16 @@ type CustomRendererMatcher = {
render: (...args: any[]) => ReactNode render: (...args: any[]) => ReactNode
} }
function Email({value}: {value: string}) {
const href = `mailto:${value}`
return <a href={href}>{value}</a>
}
function Phone({value}: {value: string}) {
const href = `tel:${value}`
return <a href={href}>{value}</a>
}
const customRendererForType: CustomRendererMatcher[] = [ const customRendererForType: CustomRendererMatcher[] = [
{ {
match: {type: 'boolean'}, match: {type: 'boolean'},
@ -171,7 +237,11 @@ const customRendererForType: CustomRendererMatcher[] = [
}, },
{ {
match: {type: 'string', name: 'contact_email'}, match: {type: 'string', name: 'contact_email'},
render: ({value}) => (<a href={`mailto:${value}`}>{value}</a>) render: Email
},
{
match: {type: 'string', name: 'contact_phone'},
render: Phone
} }
] ]
@ -187,7 +257,8 @@ const columns: TypeColumn[] = columnsRaw
.map(c => ({ .map(c => ({
...c, ...c,
render: findMatchingRenderer(c) || undefined, render: findMatchingRenderer(c) || undefined,
filterEditor: filterMappings[c.type as 'string' | 'number' | 'boolean' | 'date'] filterEditor: filterMappings[c.type as 'string' | 'number' | 'boolean' | 'date'],
editor: editorMappings[c.type as 'string' | 'number' | 'boolean' | 'date']
})) }))
const defaultFilterValue: TypeFilterValue = columns const defaultFilterValue: TypeFilterValue = columns
@ -202,9 +273,14 @@ const defaultFilterValue: TypeFilterValue = columns
}) })
async function mutate(auth: AuthState, onEditComplete: {value: string, columnId: string, rowId: string}) { async function mutate(auth: AuthState, onEditComplete: {value: string, columnId: string, rowId: string}) {
const result = await fetcher<any, any>(`mutation WriteRW($auth: Auth!, $onEditComplete: Boolean) { const type = typeof(onEditComplete.value)
write_rw(auth: $auth, onEditComplete: $onEditComplete) }`, const onEditCompleteByType = {rowId: onEditComplete.rowId,
{auth, onEditComplete})() columnId: onEditComplete.columnId,
value_boolean: type === 'boolean' && onEditComplete.value || null,
value_string: type === 'string' && onEditComplete.value || null}
const result = await fetcher<any, any>(`mutation WriteRW($auth: Auth!, $onEditCompleteByType: Boolean) {
write_rw(auth: $auth, onEditCompleteByType: $onEditCompleteByType) }`,
{auth, onEditCompleteByType})()
return result?.write_rw return result?.write_rw
} }
@ -216,7 +292,7 @@ const HostOfferLookupTable = ({data_ro, data_rw, refetch_rw}: HostOfferLookupTab
const onEditComplete = useCallback(async ({value, columnId, rowId}) => { const onEditComplete = useCallback(async ({value, columnId, rowId}) => {
/** For now the easiest way to ensure the user can see if data was updated in the db is by calling `refetch_rw()` /** For now the easiest way to ensure the user can see if data was updated in the db is by calling `refetch_rw()`
TODO: error handling **/ TODO: error handling **/
(value || value==='') && await mutate(auth, {value, columnId, rowId}) && refetch_rw() await mutate(auth, {value, columnId, rowId}) && refetch_rw()
}, [dataSource]) }, [dataSource])
const {i18n: {language}} = useTranslation() const {i18n: {language}} = useTranslation()
@ -245,6 +321,7 @@ const HostOfferLookupTable = ({data_ro, data_rw, refetch_rw}: HostOfferLookupTab
i18n={reactdatagridi18n || undefined} i18n={reactdatagridi18n || undefined}
style={{height: '100%'}} style={{height: '100%'}}
onEditComplete={onEditComplete} onEditComplete={onEditComplete}
groups={groups}
/> />
</div> </div>
</Box> </Box>