diff --git a/src/ClientCodeDemo.tsx b/src/ClientCodeDemo.tsx index a28ff2d..3397259 100644 --- a/src/ClientCodeDemo.tsx +++ b/src/ClientCodeDemo.tsx @@ -47,13 +47,13 @@ function DatasetSelector({selectedDatasetIds, setSelectedDatasetIds}: { value={selectedDatasetIds} onChange={(e) => setSelectedDatasetIds(e.target.value as string[])} renderValue={(selected) => - query.results!.filter(f => selected.includes(String(f.uuid))) + query.results!.filter(f => selected.includes(String(f.id))) .map(f => f.name as string) .join(', ') } > {query.results.map((dataset) => ( - {String(dataset.name)} + {String(dataset.name)} ))} diff --git a/src/Components/AttachmentUploadContext.tsx b/src/Components/AttachmentUploadContext.tsx index e2a6c35..1f7ff1b 100644 --- a/src/Components/AttachmentUploadContext.tsx +++ b/src/Components/AttachmentUploadContext.tsx @@ -42,7 +42,7 @@ export default function AttachmentUploadContextProvider({children}: PropsWithChi }, { onSuccess: (data: AxiosResponse) => { - queryClient.setQueryData([LOOKUP_KEYS.ARBITRARY_FILE, data.data.uuid], data.data) + queryClient.setQueryData([LOOKUP_KEYS.ARBITRARY_FILE, data.data.id], data.data) queryClient.invalidateQueries([LOOKUP_KEYS.ARBITRARY_FILE, "list"]) } } diff --git a/src/Components/CardActionBar.tsx b/src/Components/CardActionBar.tsx index 34b31ee..2ccd604 100644 --- a/src/Components/CardActionBar.tsx +++ b/src/Components/CardActionBar.tsx @@ -2,10 +2,8 @@ import React, {ReactNode} from "react"; import Tooltip from "@mui/material/Tooltip"; import IconButton from "@mui/material/IconButton"; import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; -import SaveIcon from "@mui/icons-material/Save"; import CloseIcon from "@mui/icons-material/Close"; import Stack from "@mui/material/Stack"; import CountBadge from "./CountBadge"; @@ -138,7 +136,7 @@ export default function CardActionBar(props: CardActionBarProps) { if (props.onEditSave!()) props.setEditing!(false) }}> - + {props.onUndo && @@ -178,7 +176,7 @@ export default function CardActionBar(props: CardActionBarProps) { > props.onDestroy && props.onDestroy()} disabled={!props.destroyable}> - + diff --git a/src/Components/CurrentUserContext.tsx b/src/Components/CurrentUserContext.tsx index 629f674..41d7b44 100644 --- a/src/Components/CurrentUserContext.tsx +++ b/src/Components/CurrentUserContext.tsx @@ -1,7 +1,7 @@ import {createContext, ReactNode, useContext, useState} from "react"; import {Configuration, KnoxUser, LoginApi, User}from "@battery-intelligence-lab/galv"; import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {AxiosResponse} from "axios"; +import {AxiosError, AxiosResponse} from "axios"; import axios from "axios"; import {useSnackbarMessenger} from "./SnackbarMessengerContext"; import Button from "@mui/material/Button"; @@ -39,10 +39,10 @@ export default function CurrentUserContextProvider({children}: {children: ReactN password }) const api_handler = new LoginApi(get_config()) - const Login = useMutation( + const Login = useMutation, AxiosError, void>( () => api_handler.loginCreate.bind(new LoginApi(get_config()))(), { - onSuccess: (data: AxiosResponse) => { + onSuccess: (data) => { window.localStorage.setItem('user', JSON.stringify(data.data)) setUser(data.data as unknown as LoginUser) queryClient.removeQueries({predicate: () => true}) diff --git a/src/Components/FetchResourceContext.tsx b/src/Components/FetchResourceContext.tsx index eedb334..52f3001 100644 --- a/src/Components/FetchResourceContext.tsx +++ b/src/Components/FetchResourceContext.tsx @@ -1,11 +1,19 @@ import {createContext, ReactNode, useContext} from "react"; import {useCurrentUser} from "./CurrentUserContext"; -import {API_HANDLERS, API_SLUGS, AutocompleteKey, DISPLAY_NAMES, is_lookup_key, LookupKey} from "../constants"; +import { + API_HANDLERS, + API_SLUGS, + AutocompleteKey, + DISPLAY_NAMES, + is_lookup_key, + LookupKey +} from "../constants"; import {AxiosError, AxiosResponse} from "axios"; import { + MutationFunction, QueryFunction, useInfiniteQuery, - type UseInfiniteQueryResult, useQuery, + type UseInfiniteQueryResult, useMutation, UseMutationOptions, UseMutationResult, useQuery, useQueryClient, UseQueryOptions, UseQueryResult } from "@tanstack/react-query"; @@ -25,6 +33,30 @@ export type ListQueryResult = UseInfiniteQueryResult & { results: T[] | null | undefined } +type RetrieveOptions = { + extra_query_options?: UseQueryOptions, AxiosError>, + with_result?: (result: AxiosResponse) => AxiosResponse, + on_error?: (error: AxiosError) => AxiosResponse|undefined +} +type UpdateTVariables = Partial & {id: string|number} +type UpdateOptions = { + extra_query_options?: UseMutationOptions, AxiosError>, + before_cache?: (result: AxiosResponse) => AxiosResponse, + after_cache?: (result: AxiosResponse, variables: UpdateTVariables) => void, + on_error?: (error: AxiosError, variables: UpdateTVariables) => AxiosResponse|undefined +} +type CreateOptions = { + extra_query_options?: UseMutationOptions, AxiosError>, + before_cache?: (result: AxiosResponse) => AxiosResponse, + after_cache?: (result: AxiosResponse, variables: Partial) => void, + on_error?: (error: AxiosError, variables: Partial) => AxiosResponse|undefined +} +type DeleteOptions = { + extra_query_options?: UseMutationOptions, AxiosError>, + after?: () => void, + on_error?: (error: AxiosError, variables: T) => void +} + export interface IFetchResourceContext { // Returns null when lookup_key is undefined. Otherwise, returns undefined until data are fetched, then T[] useListQuery: ( @@ -33,18 +65,31 @@ export interface IFetchResourceContext { useRetrieveQuery: ( lookup_key: LookupKey, resource_id: string|number, - options?: { - extra_query_options?: UseQueryOptions, AxiosError>, - with_result?: (r: AxiosResponse) => AxiosResponse, - on_error?: (e: AxiosError) => AxiosResponse|undefined - } + options?: RetrieveOptions ) => UseQueryResult, AxiosError> + useUpdateQuery: ( + lookup_key: LookupKey, + options?: UpdateOptions + ) => UseMutationResult, AxiosError, UpdateTVariables> + useCreateQuery: ( + lookup_key: LookupKey, + options?: CreateOptions + ) => UseMutationResult, AxiosError, Partial> + useDeleteQuery: ( + lookup_key: LookupKey, + options?: DeleteOptions + ) => UseMutationResult, AxiosError, T> } export const FetchResourceContext = createContext({} as IFetchResourceContext) export const useFetchResource = () => useContext(FetchResourceContext) +const get_error_detail = (e: AxiosError) => e.response?.data?.detail ?? + Object.entries(e.response?.data ?? {}) + .map(([k, v]) => `${k}: ${v}`) + .join(', ') + export default function FetchResourceContextProvider({children}: {children: ReactNode}) { const extract_limit_offset = (url: string|null|undefined) => { const safe_number = (n: string | null) => n && !isNaN(parseInt(n))? parseInt(n) : undefined @@ -82,7 +127,7 @@ export default function FetchResourceContextProvider({children}: {children: Reac else data = resource queryClient.setQueryData( - [lookup_key, resource.uuid ?? resource.id ?? "no id in List response"], + [lookup_key, resource.id ?? "no id in List response"], data ) }) @@ -129,13 +174,13 @@ export default function FetchResourceContextProvider({children}: {children: Reac const api_handler = new API_HANDLERS[lookup_key](config) const get = api_handler[ `${API_SLUGS[lookup_key]}Retrieve` as keyof typeof api_handler - ] as (uuid: string) => Promise> + ] as (id: string) => Promise> const after = options?.with_result? options.with_result : (r: AxiosResponse) => r const on_error_fn = options?.on_error? options.on_error : (e: AxiosError) => { postSnackbarMessage({ message: `Error retrieving ${DISPLAY_NAMES[lookup_key]}/${resource_id} - (HTTP ${e.response?.status} - ${e.response?.statusText}): ${e.response?.data?.detail}`, + (HTTP ${e.response?.status} - ${e.response?.statusText}): ${get_error_detail(e)}`, severity: 'error' }) } @@ -160,7 +205,165 @@ export default function FetchResourceContextProvider({children}: {children: Reac return useQuery, AxiosError>(query_options) } - return + const useUpdateQuery: IFetchResourceContext["useUpdateQuery"] = ( + lookup_key: LookupKey, + options?: UpdateOptions + ) => { + const queryClient = useQueryClient() + const {postSnackbarMessage} = useSnackbarMessenger() + const config = new Configuration({ + basePath: process.env.VITE_GALV_API_BASE_URL, + accessToken: useCurrentUser().user?.token + }) + const api_handler = new API_HANDLERS[lookup_key](config) + const partialUpdate = api_handler[ + `${API_SLUGS[lookup_key]}PartialUpdate` as keyof typeof api_handler + ] as (id: string, data: Partial) => Promise> + + const pre_cache = options?.before_cache? options.before_cache : (r: AxiosResponse) => r + // (r, v) => ({r, v}) does nothing except stop TS from complaining about unused variables + const post_cache = options?.after_cache? + options.after_cache : (r: AxiosResponse, v: UpdateTVariables) => ({r, v}) + // Need v so TS recognises the function as callable with 2 arguments + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const on_error_fn = options?.on_error? options.on_error : (e: AxiosError, v: UpdateTVariables) => { + postSnackbarMessage({ + message: `Error updating ${DISPLAY_NAMES[lookup_key]}/${v.id ?? v.id} + (HTTP ${e.response?.status} - ${e.response?.statusText}): ${get_error_detail(e)}`, + severity: 'error' + }) + } + + const mutationFn: MutationFunction, Partial> = + (data: Partial) => partialUpdate + .bind(api_handler)(String(data.id ?? data.id), data) + .then(pre_cache) + + const mutation_options: UseMutationOptions, AxiosError, Partial> = { + mutationKey: [lookup_key, 'update'], + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + mutationFn: mutationFn, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onSuccess: (data: AxiosResponse, variables: UpdateTVariables) => { + // Update cache + const queryKey = [lookup_key, variables.id] + queryClient.setQueryData(queryKey, data) + // Invalidate list cache + queryClient.invalidateQueries([lookup_key, 'list']) + // Invalidate autocomplete cache + queryClient.invalidateQueries(['autocomplete']) + post_cache(data, variables) + }, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onError: on_error_fn, + ...options?.extra_query_options + } + return useMutation, AxiosError, UpdateTVariables>(mutation_options) + } + + const useCreateQuery: IFetchResourceContext["useCreateQuery"] = ( + lookup_key: LookupKey, + options?: CreateOptions + ) => { + const queryClient = useQueryClient() + const {postSnackbarMessage} = useSnackbarMessenger() + const config = new Configuration({ + basePath: process.env.VITE_GALV_API_BASE_URL, + accessToken: useCurrentUser().user?.token + }) + const api_handler = new API_HANDLERS[lookup_key](config) + const create = api_handler[ + `${API_SLUGS[lookup_key]}Create` as keyof typeof api_handler + ] as (data: Partial) => Promise> + + const pre_cache = options?.before_cache? options.before_cache : (r: AxiosResponse) => r + // (r, v) => ({r, v}) does nothing except stop TS from complaining about unused variables + const post_cache = options?.after_cache? + options.after_cache : (r: AxiosResponse, v: Partial) => ({r, v}) + const on_error_fn = options?.on_error? options.on_error : (e: AxiosError, v: Partial) => { + postSnackbarMessage({ + message: `Error creating ${DISPLAY_NAMES[lookup_key]} + ${v.name ?? v.title ?? v.identifier ?? v.model ?? v.username} + (HTTP ${e.response?.status} - ${e.response?.statusText}): ${get_error_detail(e)}`, + severity: 'error' + }) + } + + const mutationFn: MutationFunction, Partial> = + (data: Partial) => create.bind(api_handler)(data).then(pre_cache) + + const mutation_options: UseMutationOptions, AxiosError, Partial> = { + mutationKey: [lookup_key, 'create'], + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + mutationFn: mutationFn, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onSuccess: (data: AxiosResponse, variables: Partial) => { + // Update cache + const queryKey = [lookup_key, data.data.id ?? "no id in Create response"] + queryClient.setQueryData(queryKey, data) + // Invalidate list cache + queryClient.invalidateQueries([lookup_key, 'list']) + // Invalidate autocomplete cache + queryClient.invalidateQueries(['autocomplete']) + post_cache(data, variables) + }, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onError: on_error_fn, + ...options?.extra_query_options + } + return useMutation, AxiosError, Partial>(mutation_options) + } + + const useDeleteQuery: IFetchResourceContext["useDeleteQuery"] = ( + lookup_key: LookupKey, + options?: DeleteOptions + ) => { + const queryClient = useQueryClient() + const {postSnackbarMessage} = useSnackbarMessenger() + const config = new Configuration({ + basePath: process.env.VITE_GALV_API_BASE_URL, + accessToken: useCurrentUser().user?.token + }) + const api_handler = new API_HANDLERS[lookup_key](config) + const destroy = api_handler[ + `${API_SLUGS[lookup_key]}Destroy` as keyof typeof api_handler + ] as (id: string) => Promise> + + const on_error_fn = options?.on_error? options.on_error : (e: AxiosError, v: T) => { + postSnackbarMessage({ + message: `Error deleting ${DISPLAY_NAMES[lookup_key]}/${v.id ?? v.id} + (HTTP ${e.response?.status} - ${e.response?.statusText}): ${get_error_detail(e)}`, + severity: 'error' + }) + } + + const mutationFn: MutationFunction, T> = + (data: T) => destroy.bind(api_handler)(String(data.id)) + + const mutation_options: UseMutationOptions, AxiosError, T> = { + mutationKey: [lookup_key, 'delete'], + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + mutationFn: mutationFn, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onSuccess: (data: AxiosResponse, variables: T) => { + // Invalidate cache + queryClient.removeQueries([lookup_key, variables.id]) + // Invalidate list cache + queryClient.invalidateQueries([lookup_key, 'list']) + // Invalidate autocomplete cache + queryClient.invalidateQueries(['autocomplete']) + if (options?.after) options.after() + }, + // @ts-expect-error - TS incorrectly infers that TVariables can be of type void + onError: on_error_fn, + ...options?.extra_query_options + } + return useMutation, AxiosError, T>(mutation_options) + } + + return {children} } diff --git a/src/Components/Mapping.tsx b/src/Components/Mapping.tsx index df189bb..778418c 100644 --- a/src/Components/Mapping.tsx +++ b/src/Components/Mapping.tsx @@ -1,5 +1,5 @@ -import {ICONS, LOOKUP_KEYS, PATHS} from "../constants"; -import React, {useEffect, useState} from "react"; +import {ICONS, key_to_type, LOOKUP_KEYS, PATHS} from "../constants"; +import React, {useState} from "react"; import Stack from "@mui/material/Stack"; import ApiResourceContextProvider, {useApiResource} from "./ApiResourceContext"; import Button from "@mui/material/Button"; @@ -33,7 +33,13 @@ import Modal from "@mui/material/Modal"; import ListItemIcon from "@mui/material/ListItemIcon"; import TextField from "@mui/material/TextField"; import FilledInput from "@mui/material/FilledInput"; -import {BaseResource} from "./ResourceCard"; +import {BaseResource, Permissions} from "./ResourceCard"; +import PrettyResource from "./prettify/PrettyResource"; +import Box from "@mui/material/Box"; +import Alert from "@mui/material/Alert"; +import {to_type_value_notation_wrapper, TypeValueNotation, TypeValueNotationWrapper} from "./TypeValueNotation"; +import PrettyObject from "./prettify/PrettyObject"; +import {Theme} from "@mui/material/styles"; type ColumnType = { url: string, @@ -45,6 +51,7 @@ type ColumnType = { is_required: boolean } type ObservedFile = BaseResource & { + id: string summary: Record> applicable_mappings: DB_MappingResource[] mapping?: string @@ -58,12 +65,18 @@ type MapEntry = { } type Map = Record type MappingResource = { - uuid: string + id: string url: string name: string is_valid: boolean + in_use?: boolean map: Map missing?: number + team: string|null + permissions?: Permissions + read_access_level?: number + edit_access_level?: number + delete_access_level?: number } // A Map but the column_type is an id, as stored in the database @@ -269,7 +282,7 @@ function MappingTable( }) ) - return + return
{Object.keys(summary).map((key, i) => { @@ -282,7 +295,7 @@ function MappingTable( { Object.values(summary).reduce((prev, cur) => { return cur.length > prev.length ? cur : prev - }, []).map((arr, i) => { + }, []).map((_arr, i) => { return {Object.values(summary).map((col, n) => { return {col[i]} @@ -298,20 +311,25 @@ function MappingTable( {/* Recognise */} - {Object.keys(summary).map((key, i) => - { + {Object.keys(summary).map((key, i) => { + if (!mappingResource.permissions?.write) return + + {map[key]?.column_type.name ?? "-"} + + + return { { const old = {...map} delete old[key] - const value = ct? {...old, [key]: {column_type: ct}} : old + const value = ct ? {...old, [key]: {column_type: ct}} : old safeSetMapping(value) }} selected_id={map[key]?.column_type.id ?? null} reset_name={key} /> } - )} + })} {/* Rebase and Rescale */} {/* Any new column is int/float */ @@ -327,6 +345,14 @@ function MappingTable( {Object.keys(summary).map((key, i) => { + if (!mappingResource.permissions?.write) return + + x' = (x + {map[key]?.addition}) x {map[key]?.multiplier} + + const float = map[key]?.column_type.data_type === "float" if (!float && map[key]?.column_type.data_type !== "int") return @@ -341,6 +367,7 @@ function MappingTable( value={map[key].addition ?? 0} sx={{width: width(map[key].addition ?? 0)}} type="number" + // @ts-expect-error MUI doesn't expose pattern property, but it does forward it pattern={pattern} aria-label="addition" onChange={(e) => { @@ -356,6 +383,7 @@ function MappingTable( value={map[key].multiplier ?? 1} sx={{width: width(map[key].multiplier ?? 1)}} type="number" + // @ts-expect-error MUI doesn't expose pattern property, but it does forward it pattern={pattern} aria-label="multiplier" onChange={(e) => { @@ -386,6 +414,11 @@ function MappingTable( {/* Rename */} {Object.keys(summary).map((key, i) => { + if (!mappingResource.permissions?.write) return + + {key} + + if (!map[key]) return {key} @@ -431,67 +464,94 @@ function MappingTable(
} -export function Mapping() { - const blank_map = () => ({uuid: "", url: "", name: "", is_valid: false, map: {}}) - const {apiResource: file, apiQuery: fileQuery} = useApiResource() - const {useListQuery} = useFetchResource() +function MappingManager({file}: {file: ObservedFile}) { + const blank_map = () => ({ + id: "", url: "", name: "", team: null, is_valid: false, map: {}, + permissions: {read: true, write: true, admin: true}, + read_access_level: 2, edit_access_level: 3, delete_access_level: 3 + }) + const {useListQuery, useCreateQuery, useUpdateQuery, useDeleteQuery} = useFetchResource() const {results: columns} = useListQuery(LOOKUP_KEYS.COLUMN_FAMILY) const [more, setMore] = React.useState(false) - const [mapping, setMapping] = useState(blank_map()) + const [advancedPropertiesOpen, setAdvancedPropertiesOpen] = React.useState(false) + const [mapping, setMapping] = useState( + () => { + const m = file?.applicable_mappings?.find(m => m.url === file.mapping) + return m? {...m} : blank_map() + } + ) + const navigate = useNavigate() + const updateFileMutation = useUpdateQuery(LOOKUP_KEYS.FILE) + const updateFile = (new_mapping: DB_MappingResource) => updateFileMutation.mutate( + {...file!, mapping: new_mapping.url}, + {onSuccess: () => navigate(0)} + ) + const createMapMutation = useCreateQuery( + LOOKUP_KEYS.MAPPING, + { after_cache: (r) => updateFile(r.data) } + ) + const createMap = (data: Omit) => + createMapMutation.mutate(data, {onSuccess: (data) => updateFile(data.data)}) + const updateMapMutation = useUpdateQuery(LOOKUP_KEYS.MAPPING) + const updateMap = (data: DB_MappingResource) => updateMapMutation.mutate(data) + const deleteMapMutation = useDeleteQuery(LOOKUP_KEYS.MAPPING) + const deleteMap = (data: DB_MappingResource) => deleteMapMutation.mutate(data, {onSuccess: () => navigate(0)}) + const summary = file?.summary as Record> // Summary data with children in array form - let array_summary: Record = {} + const array_summary: Record = {} + + const original_mapping = file?.applicable_mappings?.find((m) => m.id === mapping.id) + + const file_mapping_has_changed = original_mapping?.url !== file?.mapping + const mapping_map_has_changed = JSON.stringify(mapping.map) !== JSON.stringify(original_mapping?.map) + const mapping_has_changed = mapping_map_has_changed || + mapping.name !== original_mapping?.name || + mapping.team !== original_mapping?.team || + mapping.read_access_level !== original_mapping?.read_access_level || + mapping.edit_access_level !== original_mapping?.edit_access_level || + mapping.delete_access_level !== original_mapping?.delete_access_level + + const mapping_is_dirty = // new map with unsaved changes + (mapping.id === "" && Object.keys(mapping.map).length > 0) || + // old map with unsaved changes + (mapping.id !== "" && mapping_has_changed) + + const mapping_can_be_saved = mapping_is_dirty && + mapping.name !== "" && + mapping.team !== "" && + (mapping.permissions?.write ?? false) - const mapping_is_dirty = () => // new map with unsaved changes - (!mapping.uuid && Object.keys(mapping.map).length > 0) || - // old map with unsaved changes - (mapping.uuid && file?.applicable_mappings?.find(m => m.uuid === mapping.uuid)?.map !== mapping.map) + const map_can_be_applied = mapping.id !== "" && file_mapping_has_changed && (file?.permissions?.write ?? false) const safeSetMapping = (value?: string) => { - const new_mapping = file?.applicable_mappings?.find((m) => m.uuid === value) + const new_mapping = file?.applicable_mappings?.find((m) => m.id === value) // Check whether we need to warn about discarding changes. - if (mapping_is_dirty() && !window.confirm(`Discard unsaved changes to map ${mapping.name}?`)) + if (mapping_is_dirty && !window.confirm(`Discard unsaved changes to mapping '${mapping.name}'?`)) return - setMapping(new_mapping ?? blank_map()) + setMapping(new_mapping? {...new_mapping} : blank_map()) } - useEffect(() => { - array_summary = {} - Object.keys(summary).forEach((key) => { - const arr = []; - const max = Object.keys(summary).reduce((prev, cur) => { - return parseInt(cur) > prev ? parseInt(cur) : prev - }, 0) - for (let i = 0; i < max; i++) { - arr.push(summary[key][i] ?? undefined) - } - array_summary[key] = Object.values(summary[key]) - }) - safeSetMapping(file?.mapping) - }, [file]) - - const loadingBody = - - const errorBody = E} - title="Error" - subheader="Error loading mapping" - /> + Object.keys(summary).forEach((key) => { + const arr = []; + const max = Object.keys(summary).reduce((prev, cur) => { + return parseInt(cur) > prev ? parseInt(cur) : prev + }, 0) + for (let i = 0; i < max; i++) { + arr.push(summary[key][i] ?? undefined) } - /> + array_summary[key] = Object.values(summary[key]) + }) - const contentBody = t.spacing(1)}} elevation={0} > Column Mapping for { - file?.uuid && - + file?.id && + } @@ -511,66 +571,198 @@ export function Mapping() { - Mapping: - {array_summary && mapping && columns && - setMapping(map_to_db_map(map))} - summary={array_summary} - /> - } - {/* Load mapping */ - file?.applicable_mappings && - - Load mapping: - - - } - { - - setMapping({...mapping, name: e.target.value})} - /> - - + { !file? + Failed to load file data, please try refreshing the page. : + <> + {/* Load mapping */ + file?.applicable_mappings && + + + Mapping: + + + + + } + { + + + { mapping.permissions?.write && <> + setMapping({...mapping, name: e.target.value})} + error={mapping_is_dirty && !mapping.name} + helperText={mapping_is_dirty && !mapping.name? "Name is required" : undefined} + /> + { + if (typeof v._value !== "string") return + setMapping({...mapping, team: v._value}) + }} + /> + } + { + mapping.permissions?.write && + } + + <> + { + mapping.in_use && mapping_map_has_changed && + + Updating a map that is used for datafiles will cause those datafiles to be re-parsed. + + } + {!mapping.team && mapping_can_be_saved && Mapping must belong to a team.} + + + t.spacing(2), + paddingTop: (t: Theme) => t.spacing(1) + }} + > + { + const is_number = (x: unknown): x is number => typeof x === "number" + const as_number = (x: unknown) => is_number(x)? x : undefined + setMapping({ + ...mapping, + read_access_level: as_number(v.read_access_level._value) ?? mapping.read_access_level, + edit_access_level: as_number(v.edit_access_level._value) ?? mapping.edit_access_level, + delete_access_level: as_number(v.delete_access_level._value) ?? mapping.delete_access_level + }) + }} + extractPermissions={true} + canEditKeys={false} + /> + + + + + } + {array_summary && mapping && columns && + + setMapping(map_to_db_map(map))} + summary={array_summary} + /> + + } + } +} + +export function Mapping() { + const {apiResource: file, apiQuery: fileQuery} = useApiResource() + + const loadingBody = + + const errorBody = E} + title="Error" + subheader="Error loading mapping" + /> + } + /> return } /> } diff --git a/src/Components/Representation.tsx b/src/Components/Representation.tsx index 6979156..b8af76d 100644 --- a/src/Components/Representation.tsx +++ b/src/Components/Representation.tsx @@ -14,11 +14,11 @@ export function representation({data, lookup_key}: {data: BaseResource, lookup_k .map((e) => e[1]) .join(" ") - return s.length? s : `${DISPLAY_NAMES[lookup_key]} ${data.uuid ?? data.id}` + return s.length? s : `${DISPLAY_NAMES[lookup_key]} ${data.id ?? data.id}` } catch (error) { - console.error(`Could not represent ${lookup_key} ${data?.uuid ?? data?.id}`, {args: {data, lookup_key}, error}) + console.error(`Could not represent ${lookup_key} ${data?.id ?? data?.id}`, {args: {data, lookup_key}, error}) } - return String(data.uuid ?? data.id ?? 'unknown') + return String(data.id ?? data.id ?? 'unknown') } export type RepresentationProps = { diff --git a/src/Components/ResourceCard.tsx b/src/Components/ResourceCard.tsx index b079632..bbcdbd1 100644 --- a/src/Components/ResourceCard.tsx +++ b/src/Components/ResourceCard.tsx @@ -66,13 +66,14 @@ import ResourceStatuses from "./ResourceStatuses"; export type Permissions = { read?: boolean, write?: boolean, create?: boolean, destroy?: boolean } type child_keys = "cells"|"equipment"|"schedules" -export type BaseResource = ({uuid: string} | {id: number}) & { +type CoreProperties = { url: string, permissions?: Permissions, - team?: string, + team?: string|null, family?: string, cycler_tests?: string[], } & {[key in child_keys]?: string[]} & SerializableObject +export type BaseResource = { id: string|number } & CoreProperties export type Family = BaseResource & ({cells: string[]} | {equipment: string[]} | {schedules: string[]}) export type Resource = { family: string, cycler_tests: string[] } & BaseResource export type AutocompleteResource = { value: string, ld_value: string, url: string, id: number } @@ -143,7 +144,7 @@ function ResourceCard( const api_handler = new API_HANDLERS[lookup_key](config) const patch = api_handler[ `${API_SLUGS[lookup_key]}PartialUpdate` as keyof typeof api_handler - ] as (uuid: string, data: SerializableObject) => Promise> + ] as (id: string, data: SerializableObject) => Promise> const queryClient = useQueryClient() const update_mutation = useMutation, AxiosError, SerializableObject>( @@ -212,7 +213,7 @@ function ResourceCard( return const destroy = api_handler[ `${API_SLUGS[lookup_key]}Destroy` as keyof typeof api_handler - ] as (uuid: string) => Promise> + ] as (id: string) => Promise> destroy.bind(api_handler)(String(resource_id)) .then(() => queryClient.invalidateQueries([lookup_key, 'list'])) .then(() => { @@ -305,12 +306,12 @@ function ResourceCard( Inherited from {family? : FAMILY_ICON && }/> } } {family && family_key && { const data = deep_copy(d) @@ -418,7 +419,7 @@ function ResourceCard( lookup_key={lookup_key} prefix={family_key && family ? : undefined} diff --git a/src/Components/ResourceChip.tsx b/src/Components/ResourceChip.tsx index 9e52731..4610d44 100644 --- a/src/Components/ResourceChip.tsx +++ b/src/Components/ResourceChip.tsx @@ -23,7 +23,7 @@ export type ResourceFamilyChipProps = { export function ResourceChip( {resource_id, lookup_key, loading, error, success, short_name, ...chipProps}: ResourceFamilyChipProps ) { - // console.log(`ResourceChip`, {uuid, lookup_key, loading, error, success, chipProps}) + // console.log(`ResourceChip`, {id, lookup_key, loading, error, success, chipProps}) const { classes } = useStyles(); const {passesFilters} = useContext(FilterContext) @@ -44,7 +44,7 @@ export function ResourceChip( lookup_key={lookup_key} prefix={(!short_name && family) ? : undefined @@ -64,7 +64,7 @@ export function ResourceChip( lookup_key={lookup_key} prefix={(!short_name && family) ? : undefined diff --git a/src/Components/ResourceList.tsx b/src/Components/ResourceList.tsx index f3145c7..3cd0268 100644 --- a/src/Components/ResourceList.tsx +++ b/src/Components/ResourceList.tsx @@ -46,7 +46,7 @@ export function ResourceList({lookup_key}: ResourceListP content = query.results.map( (resource: T, i) => ) diff --git a/src/Components/ResourceStatuses.tsx b/src/Components/ResourceStatuses.tsx index a120475..9c89d3d 100644 --- a/src/Components/ResourceStatuses.tsx +++ b/src/Components/ResourceStatuses.tsx @@ -42,7 +42,7 @@ export default function ResourceStatuses({lookup_key}: {lookup_key: LookupKey}) statuses.push( + } diff --git a/src/Components/SelectedResourcesPane.tsx b/src/Components/SelectedResourcesPane.tsx index 5354b17..246ac76 100644 --- a/src/Components/SelectedResourcesPane.tsx +++ b/src/Components/SelectedResourcesPane.tsx @@ -72,7 +72,7 @@ export function DownloadButton({target_urls, ...props}: {target_urls: string|str const api_handler = new API_HANDLERS[components.lookup_key](config) const get = api_handler[ `${API_SLUGS[components.lookup_key]}Retrieve` as keyof typeof api_handler - ] as (uuid: string) => Promise> + ] as (id: string) => Promise> return get.bind(api_handler)(components.resource_id) } diff --git a/src/Components/__mocks__/Representation.tsx b/src/Components/__mocks__/Representation.tsx index a16d2c3..1515e2f 100644 --- a/src/Components/__mocks__/Representation.tsx +++ b/src/Components/__mocks__/Representation.tsx @@ -8,7 +8,7 @@ import {BaseResource} from "../ResourceCard"; import {LookupKey} from "../../constants"; export function representation(params: {data: BaseResource, lookup_key: LookupKey}): string { - return `representation: ${params.lookup_key} [${params.data.uuid ?? params.data.id}]` + return `representation: ${params.lookup_key} [${params.data.id ?? params.data.id}]` } export default function Representation(params: RepresentationProps) { diff --git a/src/Components/misc.tsx b/src/Components/misc.tsx index d398fe0..ec5ce06 100644 --- a/src/Components/misc.tsx +++ b/src/Components/misc.tsx @@ -1,7 +1,7 @@ import {is_lookup_key, LookupKey, PATHS, Serializable} from "../constants"; export type ObjectReferenceProps = - { uuid: string } | + { id: string } | { id: number } | { url: string } @@ -10,8 +10,8 @@ export function id_from_ref_props(props: ObjectRefere if (typeof props === 'number') return props as T if (typeof props === 'object') { - if ('uuid' in props) { - return props.uuid as T + if ('id' in props) { + return props.id as T } else if ('id' in props) { return props.id as T } @@ -29,7 +29,7 @@ export function id_from_ref_props(props: ObjectRefere } /** - * If `url` looks like a valid url for a resource, return the lookup key and uuid. + * If `url` looks like a valid url for a resource, return the lookup key and id. * @param url */ export function get_url_components(url: string): diff --git a/src/Components/prettify/PrettyObject.tsx b/src/Components/prettify/PrettyObject.tsx index 2060a4c..f1139d6 100644 --- a/src/Components/prettify/PrettyObject.tsx +++ b/src/Components/prettify/PrettyObject.tsx @@ -134,7 +134,7 @@ export function PrettyObjectFromQuery( const target_api_handler = new API_HANDLERS[lookup_key](config) const target_get = target_api_handler[ `${API_SLUGS[lookup_key]}Retrieve` as keyof typeof target_api_handler - ] as (uuid: string) => Promise> + ] as (id: string) => Promise> const target_query = useQuery, AxiosError>({ queryKey: [lookup_key, resource_id], diff --git a/src/Components/prettify/PrettyResource.tsx b/src/Components/prettify/PrettyResource.tsx index c8ec132..bf71df2 100644 --- a/src/Components/prettify/PrettyResource.tsx +++ b/src/Components/prettify/PrettyResource.tsx @@ -8,7 +8,7 @@ import clsx from "clsx"; import ButtonBase from "@mui/material/ButtonBase"; import ResourceChip from "../ResourceChip"; import {PrettyComponentProps, PrettyString} from "./Prettify"; -import Autocomplete, {createFilterOptions} from "@mui/material/Autocomplete"; +import Autocomplete, {AutocompleteProps, createFilterOptions} from "@mui/material/Autocomplete"; import CircularProgress from "@mui/material/CircularProgress"; import {representation} from "../Representation"; import {useFetchResource} from "../FetchResourceContext"; @@ -23,7 +23,8 @@ export type PrettyResourceSelectProps = { } & PrettyComponentProps & Partial> export const PrettyResourceSelect = ( - {target, onChange, allow_new, lookup_key}: PrettyResourceSelectProps + {target, onChange, allow_new, lookup_key, edit_mode, ...autocompleteProps}: + PrettyResourceSelectProps & Partial, "onChange">> ) => { const { useListQuery } = useFetchResource(); const [modalOpen, setModalOpen] = useState(false) @@ -90,6 +91,7 @@ export const PrettyResourceSelect = ( } (
    {params.children}
}} + {...autocompleteProps} /> } @@ -173,7 +176,6 @@ export default function PrettyResource( if (!lookup_key) throw new Error(`PrettyResource: lookup_key is undefined and unobtainable from value ${target._value}`) return { - queryClient.setQueryData([LOOKUP_KEYS.FILE, resource.uuid], {...r, data: resource}) + queryClient.setQueryData([LOOKUP_KEYS.FILE, resource.id], {...r, data: resource}) }) } catch (e) { console.error("Error updating cache from list data.", e) diff --git a/src/client_templates/python.py b/src/client_templates/python.py index 7134c85..30074b3 100644 --- a/src/client_templates/python.py +++ b/src/client_templates/python.py @@ -56,7 +56,7 @@ if verbose: print(f"Downloading dataset {dataset_id}") files_api = tag_to_api.FilesApi(api_client) # Get the specific API class we need - r = files_api.files_retrieve({"uuid": dataset_id}) # Call the API method to retrieve the dataset + r = files_api.files_retrieve({"id": dataset_id}) # Call the API method to retrieve the dataset # Response.response contains the raw response information if r.response.status != 200: raise Exception(( diff --git a/src/constants.ts b/src/constants.ts index c4c6694..168d6aa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -69,6 +69,8 @@ import { TypeChangerSupportedTypeName } from "./Components/prettify/TypeChanger"; import {TypeValueNotation} from "./Components/TypeValueNotation"; +import SaveIcon from "@mui/icons-material/Save"; +import DeleteIcon from "@mui/icons-material/Delete"; /** * The basic unit of data passed around the frontend is a Serializable. @@ -196,6 +198,8 @@ export const ICONS = { MANAGE_ACCOUNT: ManageAccountsIcon, LOGOUT: LogoutIcon, CREATE: AddCircleIcon, + DELETE: DeleteIcon, + SAVE: SaveIcon, FORK: ForkRightIcon, CANCEL: CancelIcon, CHECK: CheckCircleIcon, @@ -360,7 +364,7 @@ export const API_HANDLERS = { * ``` * const target_get = target_api_handler[ * `${API_SLUGS[lookup_key]}Retrieve` as keyof typeof target_api_handler - * ] as (uuid: string) => Promise> + * ] as (id: string) => Promise> * ``` */ export const API_SLUGS = { @@ -441,7 +445,7 @@ const team_fields: {[key: string]: Field} = { validation_results: {readonly: true, type: "object", many: true}, } const generic_fields: {[key: string]: Field} = { - uuid: {readonly: true, type: "string"}, + id: {readonly: true, type: "string"}, ...always_fields, } const autocomplete_fields: {[key: string]: Field} = { @@ -727,18 +731,18 @@ export const FIELDS = { /** * Names used by the backend to filter by each resource type. * E.g. to look up all cells in a cell family, we would filter using - * the querystring `?family_uuid=uuid`. + * the querystring `?family_id=id`. * It is the responsibility of the frontend to ensure that the * filter names are employed in the correct context -- * cell, equipment, and schedule all share the 'family' filter, * so the url path must also be appropriate. export const FILTER_NAMES = { - [LOOKUP_KEYS.CELL_FAMILY]: "family_uuid", - [LOOKUP_KEYS.EQUIPMENT_FAMILY]: "family_uuid", - [LOOKUP_KEYS.SCHEDULE_FAMILY]: "family_uuid", - [LOOKUP_KEYS.CELL]: "cell_uuid", - [LOOKUP_KEYS.EQUIPMENT]: "equipment_uuid", - [LOOKUP_KEYS.SCHEDULE]: "schedule_uuid", + [LOOKUP_KEYS.CELL_FAMILY]: "family_id", + [LOOKUP_KEYS.EQUIPMENT_FAMILY]: "family_id", + [LOOKUP_KEYS.SCHEDULE_FAMILY]: "family_id", + [LOOKUP_KEYS.CELL]: "cell_id", + [LOOKUP_KEYS.EQUIPMENT]: "equipment_id", + [LOOKUP_KEYS.SCHEDULE]: "schedule_id", [LOOKUP_KEYS.TEAM]: "team_id", } as const */ diff --git a/src/styles/UseStyles.ts b/src/styles/UseStyles.ts index 743c6ed..c402bbe 100644 --- a/src/styles/UseStyles.ts +++ b/src/styles/UseStyles.ts @@ -269,6 +269,9 @@ export default makeStyles()((theme) => { zIndex: 5000, }, typeChangerResourcePopover: {}, + mappingTable: { + '& td > svg': {verticalAlign: "middle"}, + }, mappingTableHeadRow: { '& th, & td': { fontWeight: "bold", diff --git a/src/test/ResourceCard.test.tsx b/src/test/ResourceCard.test.tsx index 59a2da7..481ee51 100644 --- a/src/test/ResourceCard.test.tsx +++ b/src/test/ResourceCard.test.tsx @@ -37,7 +37,7 @@ const api_data: { } = { cell: { url: "http://example.com/cells/0001-0001-0001-0001", - uuid: "0001-0001-0001-0001", + id: "0001-0001-0001-0001", identifier: 'Test Cell 1', family: "http://example.com/cell_families/1000-1000-1000-1000", team: "http://example.com/teams/1", @@ -96,7 +96,7 @@ const api_data: { }, cell_family: { url: "http://example.com/cell_families/1000-1000-1000-1000", - uuid: "1000-1000-1000-1000", + id: "1000-1000-1000-1000", model: "Best Cell", manufacturer: "PowerCorp", team: "http://example.com/teams/1", @@ -113,7 +113,7 @@ const api_data: { }, cell_family_2: { url: "http://example.com/cell_families/1200-1200-1200-1200", - uuid: "1200-1200-1200-1200", + id: "1200-1200-1200-1200", model: "Value Cell", manufacturer: "BudgetCorp", team: "http://example.com/teams/1", @@ -183,11 +183,11 @@ const do_render = async () => { mockedAxios.request.mockImplementation((config: AxiosRequestConfig) => { if (config.url) { const url = config.url.replace(/\/$/, "") - if (url.endsWith(api_data.cell.uuid)) + if (url.endsWith(api_data.cell.id)) return make_axios_response(api_data.cell, {config}) - if (url.endsWith(api_data.cell_family.uuid)) + if (url.endsWith(api_data.cell_family.id)) return make_axios_response(api_data.cell_family, {config}) - if (url.endsWith(api_data.cell_family_2.uuid)) + if (url.endsWith(api_data.cell_family_2.id)) return make_axios_response(api_data.cell_family_2, {config}) if (/access_levels/.test(url)) return make_axios_response(access_levels_response, {config}) @@ -239,9 +239,9 @@ describe('ResourceCard', () => { const read_only_heading = await screen.findByRole('heading', { name: /^Read-only properties$/ }); const read_only_table = read_only_heading.parentElement!.parentElement!.nextElementSibling; expect(read_only_table).not.toBe(null); - const uuid_heading = within(read_only_table as HTMLElement).getByText(/uuid/); - within(uuid_heading.parentElement!.parentElement!.nextElementSibling as HTMLElement) - .getByText(api_data.cell.uuid); + const id_heading = within(read_only_table as HTMLElement).getByText(/id/); + within(id_heading.parentElement!.parentElement!.nextElementSibling as HTMLElement) + .getByText(api_data.cell.id); const editable_heading = await screen.findByRole('heading', { name: /^Editable properties$/ }); // Editable properties has a permissions table as its first sibling @@ -592,15 +592,15 @@ describe('ResourceCard', () => { await user.click(screen.getByRole('button', {name: /^Edit this /i})); const id_label = screen.getByRole("rowheader", {name: /^key cf$/}); const input = within(id_label.parentElement! as HTMLElement).getByRole('combobox'); - expect(input).toHaveValue(`representation: CELL_FAMILY [${api_data.cell_family.uuid}]`); + expect(input).toHaveValue(`representation: CELL_FAMILY [${api_data.cell_family.id}]`); await user.click(input) await user.clear(input) await user.keyboard("2") // should match the second cell family const autocomplete = await screen.findByRole('listbox'); - const option = within(autocomplete).getByText(`representation: CELL_FAMILY [${api_data.cell_family_2.uuid}]`); + const option = within(autocomplete).getByText(`representation: CELL_FAMILY [${api_data.cell_family_2.id}]`); await user.click(option); await wait() - expect(input).toHaveValue(`representation: CELL_FAMILY [${api_data.cell_family_2.uuid}]`); + expect(input).toHaveValue(`representation: CELL_FAMILY [${api_data.cell_family_2.id}]`); }) }) diff --git a/src/test/ResourceChip.test.tsx b/src/test/ResourceChip.test.tsx index 8f76266..32116d6 100644 --- a/src/test/ResourceChip.test.tsx +++ b/src/test/ResourceChip.test.tsx @@ -22,7 +22,7 @@ const mockedAxios = axios as jest.Mocked; const ResourceChip = jest.requireActual('../Components/ResourceChip').default; const data = { - uuid: "0001-0001-0001-0001", + id: "0001-0001-0001-0001", identifier: 'Test Cell 1', family: "http://example.com/cell_families/1000-1000-1000-1000", team: "http://example.com/teams/1", @@ -37,7 +37,7 @@ const data = { } const family_data = { - uuid: "1000-1000-1000-1000", + id: "1000-1000-1000-1000", identifier: 'Test Cell Family 1', team: "http://example.com/teams/1" } @@ -59,5 +59,5 @@ it('renders', async () => { ) await screen.findByText(/DummyRepresentation/) - expect(screen.getByText(t => t.includes(data.uuid))).toBeInTheDocument() + expect(screen.getByText(t => t.includes(data.id))).toBeInTheDocument() }) diff --git a/src/test/ResourceCreator.test.tsx b/src/test/ResourceCreator.test.tsx index a47dada..5ac9c58 100644 --- a/src/test/ResourceCreator.test.tsx +++ b/src/test/ResourceCreator.test.tsx @@ -24,7 +24,7 @@ const mockedAxios = axios as jest.Mocked; const ResourceCreator = jest.requireActual('../Components/ResourceCreator').default; const family_data = { - uuid: "1000-1000-1000-1000", + id: "1000-1000-1000-1000", identifier: 'Test Cell Family 1', team: "http://example.com/teams/1" } diff --git a/src/test/ResourceList.test.tsx b/src/test/ResourceList.test.tsx index 0d9e003..960b6bc 100644 --- a/src/test/ResourceList.test.tsx +++ b/src/test/ResourceList.test.tsx @@ -21,9 +21,9 @@ const mockedAxios = axios as jest.Mocked; const ResourceList = jest.requireActual('../Components/ResourceList').default; const results = [ - {uuid: "0001-0001-0001-0001", identifier: 'Test Cell 1', family: "http://example.com/cell_families/1000-1000-1000-1000"}, - {uuid: "0002-0002-0002-0002", identifier: 'Test Cell 2', family: "http://example.com/cell_families/1000-1000-1000-1000"}, - {uuid: "0003-0003-0003-0003", identifier: 'Test Cell 3', family: "http://example.com/cell_families/2000-2000-2000-2000"}, + {id: "0001-0001-0001-0001", identifier: 'Test Cell 1', family: "http://example.com/cell_families/1000-1000-1000-1000"}, + {id: "0002-0002-0002-0002", identifier: 'Test Cell 2', family: "http://example.com/cell_families/1000-1000-1000-1000"}, + {id: "0003-0003-0003-0003", identifier: 'Test Cell 3', family: "http://example.com/cell_families/2000-2000-2000-2000"}, ] @@ -39,7 +39,7 @@ it('renders', async () => { ) - await screen.findByText(t => t.includes(results[0].uuid)) + await screen.findByText(t => t.includes(results[0].id)) expect(screen.getByRole('heading', {name: 'Cells'})).toBeInTheDocument(); expect(screen.getAllByText(/ResourceCard/)).toHaveLength(3);