diff --git a/backend/src/beherbergung/model/offer_rw.clj b/backend/src/beherbergung/model/offer_rw.clj index 21f7c9e..02ba995 100644 --- a/backend/src/beherbergung/model/offer_rw.clj +++ b/backend/src/beherbergung/model/offer_rw.clj @@ -2,11 +2,24 @@ (:require [clojure.spec.alpha :as s] [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 ::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] (some-> record - ;(select-keys [:xt/id :rw_note]) + (add_missing) (assoc :id (:xt/id record)))) diff --git a/backend/src/beherbergung/model/oneditcomplete.clj b/backend/src/beherbergung/model/oneditcomplete.clj index ad3c552..97f5831 100644 --- a/backend/src/beherbergung/model/oneditcomplete.clj +++ b/backend/src/beherbergung/model/oneditcomplete.clj @@ -4,9 +4,11 @@ (s/def ::rowId 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"} - :req-un [::rowId ::columnId ::value]) +(t/defobject OnEditCompleteByType {:name "OnEditCompleteByType" :kind t/input-object-kind :description "https://reactdatagrid.io/docs/api-reference#props-onEditComplete"} + :req-un [::rowId ::columnId + ::value_string ::value_boolean]) -(s/def ::onEditComplete OnEditComplete) +(s/def ::onEditCompleteByType OnEditCompleteByType) diff --git a/backend/src/beherbergung/resolver/root/ngo/get_rw.clj b/backend/src/beherbergung/resolver/root/ngo/get_rw.clj index 64fb2bd..1fa08a9 100644 --- a/backend/src/beherbergung/resolver/root/ngo/get_rw.clj +++ b/backend/src/beherbergung/resolver/root/ngo/get_rw.clj @@ -7,14 +7,9 @@ [beherbergung.model.offer-rw :as offer-rw :refer [db->graphql]] [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 :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 [_node opt ctx _info] diff --git a/backend/src/beherbergung/resolver/root/ngo/write_rw.clj b/backend/src/beherbergung/resolver/root/ngo/write_rw.clj index c360bd0..61c7a84 100644 --- a/backend/src/beherbergung/resolver/root/ngo/write_rw.clj +++ b/backend/src/beherbergung/resolver/root/ngo/write_rw.clj @@ -9,19 +9,27 @@ [clojure.edn])) (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)) (defn write_rw [_node opt ctx _info] - (let [{:keys [tx_sync tx-committed?]} (:db_ctx ctx) - [ngo:id] (auth+role->entity ctx (:auth opt) ::ngo/record)] - (boolean (when ngo:id - (let [{:keys [value columnId rowId]} (:onEditComplete opt) - t (tx_sync [;; TODO use :xtdb.api/match to verify we update the latest version instead of last write wins - [:xtdb.api/put {:xt/id rowId - :xt/spec ::offer-rw/record - (keyword columnId) value}]])] - (tx-committed? t)))))) + (let [{:keys [tx-fn-put tx-fn-call sync]} (:db_ctx ctx) + [ngo:id] (auth+role->entity ctx (:auth opt) ::ngo/record) + tx_result (when ngo:id + (let [{:keys [rowId columnId + value_boolean value_string]} (:onEditCompleteByType opt) + value (or value_boolean value_string) + doc {(keyword columnId) value}] + (tx-fn-put :write_rw + '(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)) diff --git a/frontend/search/codegen/generates.ts b/frontend/search/codegen/generates.ts index 2ef0311..6ac82b0 100644 --- a/frontend/search/codegen/generates.ts +++ b/frontend/search/codegen/generates.ts @@ -26,6 +26,29 @@ export type Auth = { 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; +}; + + +/** 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. */ export type QueryType = { __typename?: 'QueryType'; @@ -95,8 +118,12 @@ export type Get_Offers = { export type Get_Rw = { __typename?: 'get_rw'; - id?: Maybe; + /** Self descriptive. */ + id: Scalars['String']; + rw_contact_replied?: Maybe; + rw_contacted?: Maybe; rw_note?: Maybe; + rw_offer_occupied?: Maybe; }; /** 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 = ` @@ -185,6 +212,9 @@ export const GetRwDocument = ` query GetRw($auth: Auth!) { get_rw(auth: $auth) { id + rw_contacted + rw_contact_replied + rw_offer_occupied rw_note } } diff --git a/frontend/search/codegen/queries.ts b/frontend/search/codegen/queries.ts index 9eec617..2810519 100644 --- a/frontend/search/codegen/queries.ts +++ b/frontend/search/codegen/queries.ts @@ -32,6 +32,9 @@ export const get_rw = gql` query GetRw($auth: Auth!) { get_rw(auth: $auth) { id + rw_contacted + rw_contact_replied + rw_offer_occupied rw_note } }` diff --git a/frontend/search/components/ngo/HostOfferLookupTable.tsx b/frontend/search/components/ngo/HostOfferLookupTable.tsx index 79c0cad..b464a05 100644 --- a/frontend/search/components/ngo/HostOfferLookupTable.tsx +++ b/frontend/search/components/ngo/HostOfferLookupTable.tsx @@ -1,21 +1,22 @@ 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 DataGrid from '@inovua/reactdatagrid-community' +import {TypeColumn, TypeFilterValue, TypeSingleFilterValue} from "@inovua/reactdatagrid-community/types" import DateFilter from '@inovua/reactdatagrid-community/DateFilter' import StringFilter from '@inovua/reactdatagrid-community/StringFilter' import BoolFilter from '@inovua/reactdatagrid-community/BoolFilter' -import {GetOffersQuery} from "../../codegen/generates"; -import {TypeColumn, TypeFilterValue, TypeSingleFilterValue} from "@inovua/reactdatagrid-community/types"; -import NumberFilter from "@inovua/reactdatagrid-community/NumberFilter"; -import moment from "moment"; +import NumberFilter from "@inovua/reactdatagrid-community/NumberFilter" +import BoolEditor from '@inovua/reactdatagrid-community/BoolEditor' +import {GetOffersQuery} from "../../codegen/generates" +import moment from "moment" -import {useTranslation} from "react-i18next"; -import {resources} from '../../i18n/config'; -import {Box} from "@mui/material"; +import {useTranslation} from "react-i18next" +import {resources} from '../../i18n/config' +import {Box} from "@mui/material" import { fetcher } from '../../codegen/fetcher' import { useAuthStore, AuthState } from '../Login' @@ -34,6 +35,7 @@ interface ColumnRaw { type: string; editable: boolean; defaultWidth: number; + group: string; } /** @@ -48,14 +50,47 @@ const makeColumnDefinition = (data: any) => Object.keys(data) })) const columnsRaw: Partial[] = [ + { + "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", + "group": "locationCoarse", "header": "Country", "type": "string", "defaultWidth": 10 }, { "name": "place_city", + "group": "locationCoarse", "header": "City", "type": "string" }, @@ -66,45 +101,43 @@ const columnsRaw: Partial[] = [ }, { "name": "time_from_str", + "group": "time", "header": "From", "type": "date", "defaultWidth": 90 }, { "name": "time_duration_str", + "group": "time", "header": "Duration", "type": "string" }, { "name": "languages", - "header": "languages", + "header": "Languages", "type": "object", "defaultWidth": 200 }, { "name": "accessible", - "header": "accessible", + "group": "features", + "header": "Accessible", "type": "boolean", - "defaultWidth": 80 + "defaultWidth": 120 }, { "name": "animals_allowed", - "header": "allows animals", + "group": "animals", + "header": "Allowed", "type": "boolean", - "defaultWidth": 80 + "defaultWidth": 100 }, { "name": "animals_present", - "header": "has animals", + "group": "animals", + "header": "Present", "type": "boolean", - "defaultWidth": 80 - }, - { - "name": "rw_note", - "header": "Our notes", - "type": "string", - "editable": true, - "defaultWidth": 400 + "defaultWidth": 95 }, { "name": "note", @@ -114,44 +147,67 @@ const columnsRaw: Partial[] = [ }, { "name": "contact_name_full", + "group": "contact", "header": "Name", "type": "string" }, { "name": "contact_phone", + "group": "contact", "header": "Phone", "type": "string" }, { "name": "contact_email", + "group": "contact", "header": "EMail", "type": "string" }, { "name": "place_street", + "group": "address", "header": "Street", "type": "string" }, { "name": "place_street_number", - "header": "Street number", + "group": "address", + "header": "Number", "type": "string", - "defaultWidth": 80 + "defaultWidth": 100 }, { "name": "place_zip", + "group": "address", "header": "Zip", "type": "string", "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 = { string: StringFilter, boolean: BoolFilter, number: NumberFilter, date: DateFilter, } +const editorMappings = { + string: null, + boolean: BoolEditor, + number: null, + date: null +} const operatorsForType = { number: 'gte', string: 'contains', @@ -164,6 +220,16 @@ type CustomRendererMatcher = { render: (...args: any[]) => ReactNode } +function Email({value}: {value: string}) { + const href = `mailto:${value}` + return {value} +} + +function Phone({value}: {value: string}) { + const href = `tel:${value}` + return {value} +} + const customRendererForType: CustomRendererMatcher[] = [ { match: {type: 'boolean'}, @@ -171,7 +237,11 @@ const customRendererForType: CustomRendererMatcher[] = [ }, { match: {type: 'string', name: 'contact_email'}, - render: ({value}) => ({value}) + render: Email + }, + { + match: {type: 'string', name: 'contact_phone'}, + render: Phone } ] @@ -187,7 +257,8 @@ const columns: TypeColumn[] = columnsRaw .map(c => ({ ...c, 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 @@ -202,9 +273,14 @@ const defaultFilterValue: TypeFilterValue = columns }) async function mutate(auth: AuthState, onEditComplete: {value: string, columnId: string, rowId: string}) { - const result = await fetcher(`mutation WriteRW($auth: Auth!, $onEditComplete: Boolean) { - write_rw(auth: $auth, onEditComplete: $onEditComplete) }`, - {auth, onEditComplete})() + const type = typeof(onEditComplete.value) + const onEditCompleteByType = {rowId: onEditComplete.rowId, + columnId: onEditComplete.columnId, + value_boolean: type === 'boolean' && onEditComplete.value || null, + value_string: type === 'string' && onEditComplete.value || null} + const result = await fetcher(`mutation WriteRW($auth: Auth!, $onEditCompleteByType: Boolean) { + write_rw(auth: $auth, onEditCompleteByType: $onEditCompleteByType) }`, + {auth, onEditCompleteByType})() return result?.write_rw } @@ -216,7 +292,7 @@ const HostOfferLookupTable = ({data_ro, data_rw, refetch_rw}: HostOfferLookupTab 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()` TODO: error handling **/ - (value || value==='') && await mutate(auth, {value, columnId, rowId}) && refetch_rw() + await mutate(auth, {value, columnId, rowId}) && refetch_rw() }, [dataSource]) const {i18n: {language}} = useTranslation() @@ -245,6 +321,7 @@ const HostOfferLookupTable = ({data_ro, data_rw, refetch_rw}: HostOfferLookupTab i18n={reactdatagridi18n || undefined} style={{height: '100%'}} onEditComplete={onEditComplete} + groups={groups} />